Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -1,18 +1,58 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
interface EditBadDebtPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditBadDebtPage({ params }: EditBadDebtPageProps) {
|
||||
const { id } = await params;
|
||||
const badDebt = await getBadDebtById(id);
|
||||
export default function EditBadDebtPage({ params }: EditBadDebtPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!badDebt) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getBadDebtById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('데이터를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="edit" recordId={id} initialData={badDebt} />;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="edit" recordId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,18 +1,58 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
interface BadDebtDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BadDebtDetailPage({ params }: BadDebtDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const badDebt = await getBadDebtById(id);
|
||||
export default function BadDebtDetailPage({ params }: BadDebtDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!badDebt) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getBadDebtById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('데이터를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="view" recordId={id} initialData={badDebt} />;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="view" recordId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 악성채권 추심관리 목록 페이지
|
||||
*
|
||||
@@ -7,23 +9,48 @@
|
||||
* - GET /api/v1/bad-debts/summary - 통계 정보
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BadDebtCollection } from '@/components/accounting/BadDebtCollection';
|
||||
import { getBadDebts, getBadDebtSummary } from '@/components/accounting/BadDebtCollection/actions';
|
||||
import type { BadDebtSummary } from '@/components/accounting/BadDebtCollection/types';
|
||||
|
||||
export default async function BadDebtCollectionPage() {
|
||||
// 서버에서 데이터 병렬 조회
|
||||
const [badDebts, summary] = await Promise.all([
|
||||
getBadDebts({ size: 100 }),
|
||||
getBadDebtSummary(),
|
||||
]);
|
||||
const DEFAULT_SUMMARY: BadDebtSummary = {
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
collectedAmount: 0,
|
||||
pendingAmount: 0,
|
||||
collectionRate: 0,
|
||||
};
|
||||
|
||||
console.log('[BadDebtPage] Data count:', badDebts.length);
|
||||
console.log('[BadDebtPage] Summary:', summary);
|
||||
export default function BadDebtCollectionPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBadDebts>>>([]);
|
||||
const [summary, setSummary] = useState<BadDebtSummary>(DEFAULT_SUMMARY);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getBadDebts({ size: 100 }),
|
||||
getBadDebtSummary(),
|
||||
])
|
||||
.then(([badDebts, summaryResult]) => {
|
||||
setData(badDebts);
|
||||
setSummary(summaryResult);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BadDebtCollection
|
||||
initialData={badDebts}
|
||||
initialData={data}
|
||||
initialSummary={summary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,46 @@
|
||||
import { cookies } from 'next/headers';
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient';
|
||||
import type { BillRecord, BillApiData } from '@/components/accounting/BillManagement/types';
|
||||
import { transformApiToFrontend } from '@/components/accounting/BillManagement/types';
|
||||
import { getBills } from '@/components/accounting/BillManagement/actions';
|
||||
import type { BillRecord } from '@/components/accounting/BillManagement/types';
|
||||
|
||||
interface BillsPageProps {
|
||||
searchParams: Promise<{
|
||||
vendorId?: string;
|
||||
type?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 20,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
export default function BillsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const vendorId = searchParams.get('vendorId') || undefined;
|
||||
const billType = searchParams.get('type') || 'received';
|
||||
const page = searchParams.get('page') ? parseInt(searchParams.get('page')!) : 1;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
}
|
||||
const [data, setData] = useState<BillRecord[]>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function getBills(params: {
|
||||
billType?: string;
|
||||
page?: number;
|
||||
}): Promise<{
|
||||
data: BillRecord[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const queryParams = new URLSearchParams();
|
||||
useEffect(() => {
|
||||
getBills({ billType, page, perPage: 20 })
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [billType, page]);
|
||||
|
||||
if (params.billType && params.billType !== 'all') {
|
||||
queryParams.append('bill_type', params.billType);
|
||||
}
|
||||
if (params.page) {
|
||||
queryParams.append('page', String(params.page));
|
||||
}
|
||||
queryParams.append('per_page', '20');
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bills?${queryParams.toString()}`,
|
||||
{ method: 'GET', headers, cache: 'no-store' }
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[BillsPage] Fetch error:', response.status);
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const paginatedData = result.data as {
|
||||
data: BillApiData[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
return {
|
||||
data: paginatedData.data.map(transformApiToFrontend),
|
||||
pagination: {
|
||||
currentPage: paginatedData.current_page,
|
||||
lastPage: paginatedData.last_page,
|
||||
perPage: paginatedData.per_page,
|
||||
total: paginatedData.total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[BillsPage] Fetch error:', error);
|
||||
return {
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BillsPage({ searchParams }: BillsPageProps) {
|
||||
const params = await searchParams;
|
||||
const vendorId = params.vendorId;
|
||||
const billType = params.type || 'received';
|
||||
const page = params.page ? parseInt(params.page) : 1;
|
||||
|
||||
const { data, pagination } = await getBills({ billType, page });
|
||||
|
||||
return (
|
||||
<BillManagementClient
|
||||
@@ -110,4 +50,4 @@ export default async function BillsPage({ searchParams }: BillsPageProps) {
|
||||
initialBillType={billType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DepositManagement } from '@/components/accounting/DepositManagement';
|
||||
import { getDeposits } from '@/components/accounting/DepositManagement/actions';
|
||||
|
||||
export default async function DepositsPage() {
|
||||
const result = await getDeposits({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function DepositsPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getDeposits>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getDeposits({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DepositManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExpectedExpenseManagement } from '@/components/accounting/ExpectedExpenseManagement';
|
||||
import { getExpectedExpenses } from '@/components/accounting/ExpectedExpenseManagement/actions';
|
||||
|
||||
export default async function ExpectedExpensesPage() {
|
||||
// 서버에서 초기 데이터 로드
|
||||
const result = await getExpectedExpenses({
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
sortBy: 'expected_payment_date',
|
||||
sortDir: 'asc',
|
||||
});
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 50,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function ExpectedExpensesPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getExpectedExpenses>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getExpectedExpenses({
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
sortBy: 'expected_payment_date',
|
||||
sortDir: 'asc',
|
||||
})
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpectedExpenseManagement
|
||||
initialData={result.data}
|
||||
pagination={result.pagination}
|
||||
initialData={data}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SalesManagement } from '@/components/accounting/SalesManagement';
|
||||
import { getSales } from '@/components/accounting/SalesManagement/actions';
|
||||
|
||||
export default async function SalesPage() {
|
||||
const result = await getSales({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function SalesPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSales>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSales({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SalesManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VendorManagement } from '@/components/accounting/VendorManagement';
|
||||
import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
|
||||
export default async function VendorsPage() {
|
||||
const result = await getClients({ size: 100 });
|
||||
export default function VendorsPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getClients>>['data']>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getClients({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VendorManagement
|
||||
initialData={result.data}
|
||||
initialTotal={result.total}
|
||||
initialData={data}
|
||||
initialTotal={total}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { WithdrawalManagement } from '@/components/accounting/WithdrawalManagement';
|
||||
import { getWithdrawals } from '@/components/accounting/WithdrawalManagement/actions';
|
||||
|
||||
export default async function WithdrawalsPage() {
|
||||
const result = await getWithdrawals({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function WithdrawalsPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getWithdrawals>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getWithdrawals({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WithdrawalManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ItemDetailClient } from '@/components/business/construction/item-management';
|
||||
|
||||
interface ItemDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function ItemDetailPage({ params, searchParams }: ItemDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
export default function ItemDetailPage({ params }: ItemDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <ItemDetailClient itemId={id} isEditMode={isEditMode} />;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { LaborDetailClient } from '@/components/business/construction/labor-management';
|
||||
|
||||
interface LaborDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function LaborDetailPage({ params, searchParams }: LaborDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
export default function LaborDetailPage({ params }: LaborDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <LaborDetailClient laborId={id} isEditMode={isEditMode} />;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <PricingDetailClient id={id} mode="edit" />;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <PricingDetailClient id={id} mode="view" />;
|
||||
}
|
||||
@@ -1,19 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = await params;
|
||||
export default function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
useEffect(() => {
|
||||
getOrderDetailFull(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('주문 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('주문 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="edit" orderId={id} initialData={result.data} />;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '주문 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="edit" orderId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,19 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
useEffect(() => {
|
||||
getOrderDetailFull(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('주문 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('주문 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="view" orderId={id} initialData={result.data} />;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '주문 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="view" orderId={id} initialData={data} />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -17,11 +20,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function SiteEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [site, setSite] = useState<typeof MOCK_SITE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 현장 정보 조회
|
||||
setSite({ ...MOCK_SITE, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !site) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="edit" />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -17,11 +20,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function SiteDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [site, setSite] = useState<typeof MOCK_SITE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 현장 정보 조회
|
||||
setSite({ ...MOCK_SITE, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !site) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="view" />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -22,11 +25,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function StructureReviewEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [review, setReview] = useState<typeof MOCK_REVIEW | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
setReview({ ...MOCK_REVIEW, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !review) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="edit" />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
@@ -22,11 +25,24 @@ interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function StructureReviewDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const [review, setReview] = useState<typeof MOCK_REVIEW | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
useEffect(() => {
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
setReview({ ...MOCK_REVIEW, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !review) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="view" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding';
|
||||
|
||||
interface BiddingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingEditPage({ params }: BiddingEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
export default function BiddingEditPage({ params }: BiddingEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBiddingDetail>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getBiddingDetail(id)
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="edit"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding';
|
||||
|
||||
interface BiddingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
export default function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getBiddingDetail>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getBiddingDetail(id)
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="view"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
|
||||
@@ -6,7 +9,7 @@ interface EstimateEditPageProps {
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
function getEstimateDetail(id: string): EstimateDetail {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
@@ -187,15 +190,30 @@ async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
export default function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const detail = getEstimateDetail(id);
|
||||
setData(detail);
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="edit"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
|
||||
@@ -6,7 +9,7 @@ interface EstimateDetailPageProps {
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
function getEstimateDetail(id: string): EstimateDetail {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
@@ -187,15 +190,30 @@ async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
export default function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const detail = getEstimateDetail(id);
|
||||
setData(detail);
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="view"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PartnerForm from '@/components/business/construction/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/construction/partners/actions';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface PartnerEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PartnerEditPage({ params }: PartnerEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getPartner(id);
|
||||
export default function PartnerEditPage({ params }: PartnerEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPartner(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('협력업체 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('협력업체 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="edit"
|
||||
partnerId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PartnerForm from '@/components/business/construction/partners/PartnerForm';
|
||||
import { getPartner } from '@/components/business/construction/partners/actions';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface PartnerDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getPartner(id);
|
||||
export default function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPartner(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('협력업체 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('협력업체 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="view"
|
||||
partnerId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings';
|
||||
|
||||
interface SiteBriefingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getSiteBriefing(id);
|
||||
export default function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteBriefing(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('현장 설명회 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('현장 설명회 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="edit"
|
||||
briefingId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings';
|
||||
|
||||
interface SiteBriefingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getSiteBriefing(id);
|
||||
export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteBriefing(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('현장 설명회 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('현장 설명회 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="view"
|
||||
briefingId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface ContractEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
export default function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getContractDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('계약 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('계약 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="edit"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
|
||||
@@ -5,15 +9,52 @@ interface ContractDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
export default function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getContractDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('계약 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('계약 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="view"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
|
||||
interface HandoverReportEditPageProps {
|
||||
@@ -7,17 +11,52 @@ interface HandoverReportEditPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = await params;
|
||||
export default function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
useEffect(() => {
|
||||
getHandoverReportDetail(id)
|
||||
.then(result => {
|
||||
if (result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('인수인계서 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('인수인계서 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="edit"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
|
||||
interface HandoverReportDetailPageProps {
|
||||
@@ -7,17 +11,52 @@ interface HandoverReportDetailPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
useEffect(() => {
|
||||
getHandoverReportDetail(id)
|
||||
.then(result => {
|
||||
if (result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('인수인계서 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('인수인계서 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="view"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use server';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UrlCategory, UrlItem } from './ConstructionTestUrlsClient';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'시스템': '💻',
|
||||
'대시보드': '📊',
|
||||
};
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export async function getConstructionTestUrlsData(): Promise<{ categories: UrlCategory[]; lastUpdated: string }> {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] construction-pages-test-urls.md'
|
||||
);
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
return parseMdFile(fileContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
return { categories: [], lastUpdated: 'N/A' };
|
||||
}
|
||||
}
|
||||
@@ -1,152 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import ConstructionTestUrlsClient, { UrlCategory, UrlItem } from './ConstructionTestUrlsClient';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ConstructionTestUrlsClient, { UrlCategory } from './ConstructionTestUrlsClient';
|
||||
import { getConstructionTestUrlsData } from './actions';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'시스템': '💻',
|
||||
'대시보드': '📊',
|
||||
};
|
||||
export default function TestUrlsPage() {
|
||||
const [urlData, setUrlData] = useState<UrlCategory[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState('N/A');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
useEffect(() => {
|
||||
getConstructionTestUrlsData()
|
||||
.then(result => {
|
||||
setUrlData(result.categories);
|
||||
setLastUpdated(result.lastUpdated);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export default async function TestUrlsPage() {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] construction-pages-test-urls.md'
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let urlData: UrlCategory[] = [];
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
const parsed = parseMdFile(fileContent);
|
||||
urlData = parsed.categories;
|
||||
lastUpdated = parsed.lastUpdated;
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
// 파일 읽기 실패 시 빈 데이터
|
||||
urlData = [];
|
||||
}
|
||||
|
||||
return <ConstructionTestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
|
||||
}
|
||||
|
||||
// 캐싱 비활성화 - 항상 최신 md 파일 읽기
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
return <ConstructionTestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
|
||||
}
|
||||
157
src/app/[locale]/(protected)/dev/test-urls/actions.ts
Normal file
157
src/app/[locale]/(protected)/dev/test-urls/actions.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
'use server';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UrlCategory, UrlItem } from './TestUrlsClient';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'인사관리': '👥',
|
||||
'HR': '👥',
|
||||
'판매관리': '💰',
|
||||
'Sales': '💰',
|
||||
'기준정보관리': '📦',
|
||||
'Master Data': '📦',
|
||||
'생산관리': '🏭',
|
||||
'Production': '🏭',
|
||||
'설정': '⚙️',
|
||||
'Settings': '⚙️',
|
||||
'전자결재': '📝',
|
||||
'Approval': '📝',
|
||||
'회계관리': '💵',
|
||||
'Accounting': '💵',
|
||||
'게시판': '📋',
|
||||
'Board': '📋',
|
||||
'보고서': '📊',
|
||||
'Reports': '📊',
|
||||
};
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export async function getTestUrlsData(): Promise<{ categories: UrlCategory[]; lastUpdated: string }> {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] all-pages-test-urls.md'
|
||||
);
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
return parseMdFile(fileContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
return { categories: [], lastUpdated: 'N/A' };
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,30 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import TestUrlsClient, { UrlCategory, UrlItem } from './TestUrlsClient';
|
||||
'use client';
|
||||
|
||||
// 아이콘 매핑
|
||||
const iconMap: Record<string, string> = {
|
||||
'기본': '🏠',
|
||||
'인사관리': '👥',
|
||||
'HR': '👥',
|
||||
'판매관리': '💰',
|
||||
'Sales': '💰',
|
||||
'기준정보관리': '📦',
|
||||
'Master Data': '📦',
|
||||
'생산관리': '🏭',
|
||||
'Production': '🏭',
|
||||
'설정': '⚙️',
|
||||
'Settings': '⚙️',
|
||||
'전자결재': '📝',
|
||||
'Approval': '📝',
|
||||
'회계관리': '💵',
|
||||
'Accounting': '💵',
|
||||
'게시판': '📋',
|
||||
'Board': '📋',
|
||||
'보고서': '📊',
|
||||
'Reports': '📊',
|
||||
};
|
||||
import { useEffect, useState } from 'react';
|
||||
import TestUrlsClient, { UrlCategory } from './TestUrlsClient';
|
||||
import { getTestUrlsData } from './actions';
|
||||
|
||||
function getIcon(title: string): string {
|
||||
for (const [key, icon] of Object.entries(iconMap)) {
|
||||
if (title.includes(key)) return icon;
|
||||
}
|
||||
return '📄';
|
||||
}
|
||||
export default function TestUrlsPage() {
|
||||
const [urlData, setUrlData] = useState<UrlCategory[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState('N/A');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
function parseTableRow(line: string): UrlItem | null {
|
||||
// | 페이지 | URL | 상태 | 형식 파싱
|
||||
const parts = line.split('|').map(p => p.trim()).filter(p => p);
|
||||
useEffect(() => {
|
||||
getTestUrlsData()
|
||||
.then(result => {
|
||||
setUrlData(result.categories);
|
||||
setLastUpdated(result.lastUpdated);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
|
||||
|
||||
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
|
||||
const url = parts[1].replace(/`/g, ''); // backtick 제거
|
||||
const status = parts[2] || undefined;
|
||||
|
||||
// URL이 /ko로 시작하는지 확인
|
||||
if (!url.startsWith('/ko')) return null;
|
||||
|
||||
return { name, url, status };
|
||||
}
|
||||
|
||||
function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } {
|
||||
const lines = content.split('\n');
|
||||
const categories: UrlCategory[] = [];
|
||||
let currentCategory: UrlCategory | null = null;
|
||||
let currentSubCategory: { title: string; items: UrlItem[] } | null = null;
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
// Last Updated 추출
|
||||
const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (updateMatch) {
|
||||
lastUpdated = updateMatch[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// ## 카테고리 (메인 섹션)
|
||||
if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) {
|
||||
// 이전 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
currentSubCategory = null;
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
|
||||
currentCategory = {
|
||||
title,
|
||||
icon: getIcon(title),
|
||||
items: [],
|
||||
subCategories: [],
|
||||
};
|
||||
currentSubCategory = null;
|
||||
}
|
||||
|
||||
// ### 서브 카테고리
|
||||
else if (line.startsWith('### ') && currentCategory) {
|
||||
// 이전 서브카테고리 저장
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
|
||||
const subTitle = line.replace('### ', '').trim();
|
||||
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
|
||||
if (subTitle === '메인 페이지') {
|
||||
currentSubCategory = null;
|
||||
} else {
|
||||
currentSubCategory = {
|
||||
title: subTitle,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 행 파싱
|
||||
else if (line.startsWith('|') && currentCategory) {
|
||||
const item = parseTableRow(line);
|
||||
if (item) {
|
||||
if (currentSubCategory) {
|
||||
currentSubCategory.items.push(item);
|
||||
} else {
|
||||
currentCategory.items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 카테고리 저장
|
||||
if (currentCategory) {
|
||||
if (currentSubCategory) {
|
||||
currentCategory.subCategories = currentCategory.subCategories || [];
|
||||
currentCategory.subCategories.push(currentSubCategory);
|
||||
}
|
||||
categories.push(currentCategory);
|
||||
}
|
||||
|
||||
// 빈 서브카테고리 제거
|
||||
categories.forEach(cat => {
|
||||
cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0);
|
||||
});
|
||||
|
||||
return { categories, lastUpdated };
|
||||
}
|
||||
|
||||
export default async function TestUrlsPage() {
|
||||
// md 파일 경로
|
||||
const mdFilePath = path.join(
|
||||
process.cwd(),
|
||||
'claudedocs',
|
||||
'[REF] all-pages-test-urls.md'
|
||||
);
|
||||
|
||||
let urlData: UrlCategory[] = [];
|
||||
let lastUpdated = 'N/A';
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(mdFilePath, 'utf-8');
|
||||
const parsed = parseMdFile(fileContent);
|
||||
urlData = parsed.categories;
|
||||
lastUpdated = parsed.lastUpdated;
|
||||
} catch (error) {
|
||||
console.error('Failed to read md file:', error);
|
||||
// 파일 읽기 실패 시 빈 데이터
|
||||
urlData = [];
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <TestUrlsClient initialData={urlData} lastUpdated={lastUpdated} />;
|
||||
}
|
||||
|
||||
// 캐싱 비활성화 - 항상 최신 md 파일 읽기
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
}
|
||||
@@ -1,42 +1,62 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정 수정 페이지
|
||||
* 공정 수정 페이지 (Client Component)
|
||||
*/
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ProcessForm } from '@/components/process-management';
|
||||
import { getProcessById } from '@/components/process-management/actions';
|
||||
import type { Process } from '@/components/process-management/types';
|
||||
|
||||
export default async function EditProcessPage({
|
||||
export default function EditProcessPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Process | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getProcessById(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('공정 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('공정 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">공정 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProcessForm mode="edit" initialData={result.data} />;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
title: '공정을 찾을 수 없습니다',
|
||||
};
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '공정을 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${result.data.processName} - 공정 수정`,
|
||||
description: `${result.data.processCode} 공정 수정`,
|
||||
};
|
||||
}
|
||||
return <ProcessForm mode="edit" initialData={data} />;
|
||||
}
|
||||
@@ -1,48 +1,62 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정 상세 페이지
|
||||
* 공정 상세 페이지 (Client Component)
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ProcessDetail } from '@/components/process-management';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { getProcessById } from '@/components/process-management/actions';
|
||||
import type { Process } from '@/components/process-management/types';
|
||||
|
||||
export default async function ProcessDetailPage({
|
||||
export default function ProcessDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Process | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
useEffect(() => {
|
||||
getProcessById(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('공정 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('공정 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">공정 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<ContentLoadingSpinner text="공정 정보를 불러오는 중..." />}>
|
||||
<ProcessDetail process={result.data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const result = await getProcessById(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
title: '공정을 찾을 수 없습니다',
|
||||
};
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '공정을 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${result.data.processName} - 공정 상세`,
|
||||
description: `${result.data.processCode} 공정 정보`,
|
||||
};
|
||||
}
|
||||
return <ProcessDetail process={data} />;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { ReceivingDetail } from '@/components/material/ReceivingManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ReceivingDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
export default function ReceivingDetailPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <ReceivingDetail id={id} />;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { StockStatusDetail } from '@/components/material/StockStatus';
|
||||
|
||||
interface StockStatusDetailPageProps {
|
||||
@@ -6,7 +9,7 @@ interface StockStatusDetailPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function StockStatusDetailPage({ params }: StockStatusDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function StockStatusDetailPage({ params }: StockStatusDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return <StockStatusDetail id={id} />;
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 수정 페이지
|
||||
* 출하관리 - 수정 페이지 (Client Component)
|
||||
* URL: /outbound/shipments/[id]/edit
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { ShipmentEdit } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentEditPage({ params }: ShipmentEditPageProps) {
|
||||
const { id } = await params;
|
||||
export default function ShipmentEditPage({ params }: ShipmentEditPageProps) {
|
||||
const { id } = use(params);
|
||||
return <ShipmentEdit id={id} />;
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 상세 페이지
|
||||
* 출하관리 - 상세 페이지 (Client Component)
|
||||
* URL: /outbound/shipments/[id]
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { ShipmentDetail } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
|
||||
const { id } = await params;
|
||||
export default function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return <ShipmentDetail id={id} />;
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PaymentHistoryManagement } from '@/components/settings/PaymentHistoryManagement';
|
||||
import { getPayments } from '@/components/settings/PaymentHistoryManagement/actions';
|
||||
|
||||
export default async function PaymentHistoryPage() {
|
||||
const result = await getPayments({ perPage: 100 });
|
||||
export default function PaymentHistoryPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getPayments>>['data']>(undefined);
|
||||
const [pagination, setPagination] = useState<Awaited<ReturnType<typeof getPayments>>['pagination']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getPayments({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PaymentHistoryManagement
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품목 상세 조회 페이지
|
||||
* 품목 상세 조회 페이지 (Client Component)
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ItemDetailClient from '@/components/items/ItemDetailClient';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
|
||||
@@ -134,62 +136,53 @@ const mockItems: ItemMaster[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 품목 조회 함수
|
||||
* TODO: API 연동 시 fetchItemByCode()로 교체
|
||||
*/
|
||||
async function getItemByCode(itemCode: string): Promise<ItemMaster | null> {
|
||||
// API 연동 전 mock 데이터 반환
|
||||
// const item = await fetchItemByCode(itemCode);
|
||||
const item = mockItems.find(
|
||||
(item) => item.itemCode === decodeURIComponent(itemCode)
|
||||
);
|
||||
return item || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 상세 페이지
|
||||
*/
|
||||
export default async function ItemDetailPage({
|
||||
export default function ItemDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const item = await getItemByCode(id);
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [item, setItem] = useState<ItemMaster | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// API 연동 전 mock 데이터 사용
|
||||
const foundItem = mockItems.find(
|
||||
(item) => item.itemCode === decodeURIComponent(id)
|
||||
);
|
||||
setItem(foundItem || null);
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
notFound();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">품목을 찾을 수 없습니다.</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ItemDetailClient item={item} />
|
||||
</Suspense>
|
||||
<ItemDetailClient item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const item = await getItemByCode(id);
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
title: '품목을 찾을 수 없습니다',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${item.itemName} - 품목 상세`,
|
||||
description: `${item.itemCode} 품목 정보`,
|
||||
};
|
||||
}
|
||||
@@ -1,160 +1,16 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품목 목록 페이지 (Server Component)
|
||||
* 품목 목록 페이지 (Client Component)
|
||||
*
|
||||
* Next.js 15 App Router
|
||||
* 서버에서 데이터 fetching 후 Client Component로 전달
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import ItemListClient from '@/components/items/ItemListClient';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockItems: ItemMaster[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemCode: 'KD-FG-001',
|
||||
itemName: '스크린 제품 A',
|
||||
itemType: 'FG',
|
||||
unit: 'EA',
|
||||
specification: '2000x2000',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
salesPrice: 150000,
|
||||
purchasePrice: 100000,
|
||||
productCategory: 'SCREEN',
|
||||
lotAbbreviation: 'KD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemCode: 'KD-PT-001',
|
||||
itemName: '가이드레일(벽면형)',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: '2438mm',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
category3: '가이드레일',
|
||||
salesPrice: 50000,
|
||||
purchasePrice: 35000,
|
||||
partType: 'ASSEMBLY',
|
||||
partUsage: 'GUIDE_RAIL',
|
||||
installationType: '벽면형',
|
||||
assemblyType: 'M',
|
||||
assemblyLength: '2438',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemCode: 'KD-PT-002',
|
||||
itemName: '절곡품 샘플',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: 'EGI 1.55T',
|
||||
isActive: true,
|
||||
partType: 'BENDING',
|
||||
material: 'EGI 1.55T',
|
||||
length: '2000',
|
||||
salesPrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemCode: 'KD-RM-001',
|
||||
itemName: 'SPHC-SD',
|
||||
itemType: 'RM',
|
||||
unit: 'KG',
|
||||
specification: '1.6T x 1219 x 2438',
|
||||
isActive: true,
|
||||
category1: '철강재',
|
||||
purchasePrice: 1500,
|
||||
material: 'SPHC-SD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemCode: 'KD-SM-001',
|
||||
itemName: '볼트 M6x20',
|
||||
itemType: 'SM',
|
||||
unit: 'EA',
|
||||
specification: 'M6x20',
|
||||
isActive: true,
|
||||
category1: '구조재/부속품',
|
||||
category2: '볼트/너트',
|
||||
purchasePrice: 50,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
itemCode: 'KD-CS-001',
|
||||
itemName: '절삭유',
|
||||
itemType: 'CS',
|
||||
unit: 'L',
|
||||
specification: '20L',
|
||||
isActive: true,
|
||||
purchasePrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
itemCode: 'KD-FG-002',
|
||||
itemName: '철재 제품 B',
|
||||
itemType: 'FG',
|
||||
unit: 'SET',
|
||||
specification: '3000x2500',
|
||||
isActive: false,
|
||||
category1: '본체부품',
|
||||
salesPrice: 200000,
|
||||
productCategory: 'STEEL',
|
||||
lotAbbreviation: 'KD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-09T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 품목 목록 조회 함수
|
||||
* TODO: API 연동 시 fetchItems()로 교체
|
||||
*/
|
||||
async function getItems(): Promise<ItemMaster[]> {
|
||||
// API 연동 전 mock 데이터 반환
|
||||
// const items = await fetchItems();
|
||||
return mockItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 목록 페이지
|
||||
*/
|
||||
export default async function ItemsPage() {
|
||||
const items = await getItems();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ItemListClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata = {
|
||||
title: '품목 관리',
|
||||
description: '품목 목록 조회 및 관리',
|
||||
};
|
||||
export default function ItemsPage() {
|
||||
return <ItemListClient />;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 상세 페이지
|
||||
* 작업지시 상세 페이지 (Client Component)
|
||||
* URL: /production/work-orders/[id]
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { WorkOrderDetail } from '@/components/production/WorkOrders';
|
||||
|
||||
interface PageProps {
|
||||
@@ -11,7 +14,7 @@ interface PageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function WorkOrderDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function WorkOrderDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
return <WorkOrderDetail orderId={id} />;
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 상세/수정 페이지
|
||||
* 검사 상세/수정 페이지 (Client Component)
|
||||
* URL: /quality/inspections/[id]
|
||||
* 수정 모드: /quality/inspections/[id]?mode=edit
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { InspectionDetail } from '@/components/quality/InspectionManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function InspectionDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
export default function InspectionDetailPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <InspectionDetail id={id} />;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 수정 페이지
|
||||
* 단가 수정 페이지 (Client Component)
|
||||
*
|
||||
* 경로: /sales/pricing-management/[id]/edit
|
||||
* API: GET /api/v1/pricing/{id}, PUT /api/v1/pricing/{id}
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import { getPricingById, updatePricing, finalizePricing } from '@/components/pricing/actions';
|
||||
import type { PricingData } from '@/components/pricing';
|
||||
@@ -15,57 +19,79 @@ interface EditPricingPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditPricingPage({ params }: EditPricingPageProps) {
|
||||
const { id } = await params;
|
||||
export default function EditPricingPage({ params }: EditPricingPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<PricingData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 기존 단가 데이터 조회
|
||||
const pricingData = await getPricingById(id);
|
||||
useEffect(() => {
|
||||
getPricingById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('단가 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('단가 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (!pricingData) {
|
||||
// 단가 수정 핸들러
|
||||
const handleSave = async (formData: PricingData, isRevision?: boolean, revisionReason?: string) => {
|
||||
const result = await updatePricing(id, formData, revisionReason);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 수정에 실패했습니다.');
|
||||
}
|
||||
console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason });
|
||||
};
|
||||
|
||||
// 단가 확정 핸들러
|
||||
const handleFinalize = async (priceId: string) => {
|
||||
const result = await finalizePricing(priceId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 확정에 실패했습니다.');
|
||||
}
|
||||
console.log('[EditPricingPage] 단가 확정 성공:', result.data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">단가 정보를 찾을 수 없습니다</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<h2 className="text-xl font-semibold mb-2">{error || '단가 정보를 찾을 수 없습니다'}</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
올바른 단가 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 서버 액션: 단가 수정
|
||||
async function handleSave(data: PricingData, isRevision?: boolean, revisionReason?: string) {
|
||||
'use server';
|
||||
|
||||
const result = await updatePricing(id, data, revisionReason);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 수정에 실패했습니다.');
|
||||
}
|
||||
|
||||
console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason });
|
||||
}
|
||||
|
||||
// 서버 액션: 단가 확정
|
||||
async function handleFinalize(priceId: string) {
|
||||
'use server';
|
||||
|
||||
const result = await finalizePricing(priceId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 확정에 실패했습니다.');
|
||||
}
|
||||
|
||||
console.log('[EditPricingPage] 단가 확정 성공:', result.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingFormClient
|
||||
mode="edit"
|
||||
initialData={pricingData}
|
||||
initialData={data}
|
||||
onSave={handleSave}
|
||||
onFinalize={handleFinalize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 등록 페이지
|
||||
* 단가 등록 페이지 (Client Component)
|
||||
*
|
||||
* 경로: /sales/pricing-management/create?itemId=xxx
|
||||
* API: POST /api/v1/pricing
|
||||
@@ -7,63 +9,95 @@
|
||||
* item_type_code는 품목 정보에서 자동으로 가져옴 (FG, PT, SM, RM, CS 등)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import { getItemInfo, createPricing } from '@/components/pricing/actions';
|
||||
import type { PricingData } from '@/components/pricing';
|
||||
import type { PricingData, ItemInfo } from '@/components/pricing';
|
||||
|
||||
interface CreatePricingPageProps {
|
||||
searchParams: Promise<{
|
||||
itemId?: string;
|
||||
itemCode?: string;
|
||||
}>;
|
||||
}
|
||||
export default function CreatePricingPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const itemId = searchParams.get('itemId') || '';
|
||||
|
||||
export default async function CreatePricingPage({ searchParams }: CreatePricingPageProps) {
|
||||
const params = await searchParams;
|
||||
const itemId = params.itemId || '';
|
||||
const [itemInfo, setItemInfo] = useState<ItemInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 품목 정보 조회
|
||||
const itemInfo = itemId ? await getItemInfo(itemId) : null;
|
||||
useEffect(() => {
|
||||
if (!itemId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!itemInfo && itemId) {
|
||||
getItemInfo(itemId)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setItemInfo(result);
|
||||
} else {
|
||||
setError('품목 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('품목 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [itemId]);
|
||||
|
||||
// 단가 등록 핸들러
|
||||
const handleSave = async (data: PricingData) => {
|
||||
const result = await createPricing(data);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 등록에 실패했습니다.');
|
||||
}
|
||||
console.log('[CreatePricingPage] 단가 등록 성공:', result.data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">품목 정보를 찾을 수 없습니다</h2>
|
||||
<p className="text-muted-foreground">
|
||||
올바른 품목 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 품목 정보 없이 접근한 경우 (목록에서 바로 등록)
|
||||
if (!itemInfo) {
|
||||
// 품목 정보 없이 접근한 경우
|
||||
if (!itemId) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">품목을 선택해주세요</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
단가 목록에서 품목을 선택한 후 등록해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 서버 액션: 단가 등록
|
||||
// item_type_code는 data.itemType에서 자동으로 가져옴
|
||||
async function handleSave(data: PricingData) {
|
||||
'use server';
|
||||
|
||||
const result = await createPricing(data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 등록에 실패했습니다.');
|
||||
}
|
||||
|
||||
console.log('[CreatePricingPage] 단가 등록 성공:', result.data);
|
||||
if (error || !itemInfo) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">{error || '품목 정보를 찾을 수 없습니다'}</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
올바른 품목 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -73,4 +107,4 @@ export default async function CreatePricingPage({ searchParams }: CreatePricingP
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 목록 페이지
|
||||
* 단가 목록 페이지 (Client Component)
|
||||
*
|
||||
* 경로: /sales/pricing-management
|
||||
* API:
|
||||
@@ -10,302 +12,29 @@
|
||||
* 품목 목록 + 단가 목록 → 병합 → 품목별 단가 현황 표시
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PricingListClient } from '@/components/pricing';
|
||||
import type { PricingListItem, PricingStatus } from '@/components/pricing';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getPricingListData, type PricingListItem } from '@/components/pricing/actions';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
export default function PricingManagementPage() {
|
||||
const [data, setData] = useState<PricingListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 품목 API 응답 타입 (GET /api/v1/items)
|
||||
interface ItemApiData {
|
||||
id: number;
|
||||
item_type: string; // FG, PT, SM, RM, CS (품목 유형)
|
||||
code: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
category_id: number | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
useEffect(() => {
|
||||
getPricingListData()
|
||||
.then(result => {
|
||||
setData(result);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
interface ItemsApiResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
current_page: number;
|
||||
data: ItemApiData[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
// 단가 API 응답 타입 (GET /api/v1/pricing)
|
||||
interface PriceApiItem {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
item_type_code: string; // FG, PT, SM, RM, CS (items.item_type과 동일)
|
||||
item_id: number;
|
||||
client_group_id: number | null;
|
||||
purchase_price: string | null;
|
||||
processing_cost: string | null;
|
||||
loss_rate: string | null;
|
||||
margin_rate: string | null;
|
||||
sales_price: string | null;
|
||||
rounding_rule: 'round' | 'ceil' | 'floor';
|
||||
rounding_unit: number;
|
||||
supplier: string | null;
|
||||
effective_from: string;
|
||||
effective_to: string | null;
|
||||
status: 'draft' | 'active' | 'finalized';
|
||||
is_final: boolean;
|
||||
finalized_at: string | null;
|
||||
finalized_by: number | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
client_group?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
product?: {
|
||||
id: number;
|
||||
product_code: string;
|
||||
product_name: string;
|
||||
specification: string | null;
|
||||
unit: string;
|
||||
product_type: string;
|
||||
};
|
||||
material?: {
|
||||
id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
unit: string;
|
||||
product_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PricingApiResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
current_page: number;
|
||||
data: PriceApiItem[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
last_page: number;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
// API 헤더 생성
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
}
|
||||
|
||||
// 품목 유형 매핑 (type_code → 프론트엔드 ItemType)
|
||||
function mapItemType(typeCode?: string): string {
|
||||
switch (typeCode) {
|
||||
case 'FG': return 'FG'; // 제품
|
||||
case 'PT': return 'PT'; // 부품
|
||||
case 'SM': return 'SM'; // 부자재
|
||||
case 'RM': return 'RM'; // 원자재
|
||||
case 'CS': return 'CS'; // 소모품
|
||||
default: return 'PT';
|
||||
}
|
||||
}
|
||||
|
||||
// API 상태 → 프론트엔드 상태 매핑
|
||||
function mapStatus(apiStatus: string, isFinal: boolean): PricingStatus | 'not_registered' {
|
||||
if (isFinal) return 'finalized';
|
||||
switch (apiStatus) {
|
||||
case 'draft': return 'draft';
|
||||
case 'active': return 'active';
|
||||
case 'finalized': return 'finalized';
|
||||
default: return 'draft';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 호출 함수
|
||||
// ============================================
|
||||
|
||||
// 품목 목록 조회
|
||||
async function getItemsList(): Promise<ItemApiData[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PricingPage] Items API Error:', response.status, response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: ItemsApiResponse = await response.json();
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
console.warn('[PricingPage] No items data in response');
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data.data;
|
||||
} catch (error) {
|
||||
console.error('[PricingPage] Items fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 단가 목록 조회
|
||||
async function getPricingList(): Promise<PriceApiItem[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PricingPage] Pricing API Error:', response.status, response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: PricingApiResponse = await response.json();
|
||||
console.log('[PricingPage] Pricing API Response count:', result.data?.data?.length || 0);
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
console.warn('[PricingPage] No pricing data in response');
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data.data;
|
||||
} catch (error) {
|
||||
console.error('[PricingPage] Pricing fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 데이터 병합 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 품목 목록 + 단가 목록 병합
|
||||
*
|
||||
* - 품목 목록을 기준으로 순회
|
||||
* - 각 품목에 해당하는 단가 정보를 매핑 (item_type + item_id로 매칭)
|
||||
* - 단가 미등록 품목은 'not_registered' 상태로 표시
|
||||
*/
|
||||
function mergeItemsWithPricing(
|
||||
items: ItemApiData[],
|
||||
pricings: PriceApiItem[]
|
||||
): PricingListItem[] {
|
||||
// 단가 정보를 빠르게 찾기 위한 Map 생성
|
||||
// key: "{item_type}_{item_id}" (예: "FG_123", "PT_456")
|
||||
const pricingMap = new Map<string, PriceApiItem>();
|
||||
|
||||
for (const pricing of pricings) {
|
||||
const key = `${pricing.item_type_code}_${pricing.item_id}`;
|
||||
// 같은 품목에 여러 단가가 있을 수 있으므로 최신 것만 사용
|
||||
if (!pricingMap.has(key)) {
|
||||
pricingMap.set(key, pricing);
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 목록을 기준으로 병합
|
||||
return items.map((item) => {
|
||||
const key = `${item.item_type}_${item.id}`;
|
||||
const pricing = pricingMap.get(key);
|
||||
|
||||
if (pricing) {
|
||||
// 단가 등록된 품목
|
||||
return {
|
||||
id: String(pricing.id),
|
||||
itemId: String(item.id),
|
||||
itemCode: item.code,
|
||||
itemName: item.name,
|
||||
itemType: mapItemType(item.item_type),
|
||||
specification: undefined, // items API에서는 specification 미제공
|
||||
unit: item.unit || 'EA',
|
||||
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
|
||||
processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined,
|
||||
salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined,
|
||||
marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined,
|
||||
effectiveDate: pricing.effective_from,
|
||||
status: mapStatus(pricing.status, pricing.is_final),
|
||||
currentRevision: 0,
|
||||
isFinal: pricing.is_final,
|
||||
itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요)
|
||||
};
|
||||
} else {
|
||||
// 단가 미등록 품목
|
||||
return {
|
||||
id: `item_${item.id}`, // 임시 ID (단가 ID가 없으므로)
|
||||
itemId: String(item.id),
|
||||
itemCode: item.code,
|
||||
itemName: item.name,
|
||||
itemType: mapItemType(item.item_type),
|
||||
specification: undefined,
|
||||
unit: item.unit || 'EA',
|
||||
purchasePrice: undefined,
|
||||
processingCost: undefined,
|
||||
salesPrice: undefined,
|
||||
marginRate: undefined,
|
||||
effectiveDate: undefined,
|
||||
status: 'not_registered' as const,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 페이지 컴포넌트
|
||||
// ============================================
|
||||
|
||||
export default async function PricingManagementPage() {
|
||||
// 품목 목록과 단가 목록을 병렬로 조회
|
||||
const [items, pricings] = await Promise.all([
|
||||
getItemsList(),
|
||||
getPricingList(),
|
||||
]);
|
||||
|
||||
console.log('[PricingPage] Items count:', items.length);
|
||||
console.log('[PricingPage] Pricings count:', pricings.length);
|
||||
|
||||
// 데이터 병합
|
||||
const mergedData = mergeItemsWithPricing(items, pricings);
|
||||
console.log('[PricingPage] Merged data count:', mergedData.length);
|
||||
|
||||
return (
|
||||
<PricingListClient initialData={mergedData} />
|
||||
);
|
||||
return <PricingListClient initialData={data} />;
|
||||
}
|
||||
@@ -1,20 +1,48 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 견적관리 페이지 (Server Component)
|
||||
* 견적관리 페이지 (Client Component)
|
||||
*
|
||||
* 초기 데이터를 서버에서 fetch하여 Client Component에 전달
|
||||
* 초기 데이터를 useEffect에서 fetch하여 Client Component에 전달
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { QuoteManagementClient } from '@/components/quotes/QuoteManagementClient';
|
||||
import { getQuotes } from '@/components/quotes/actions';
|
||||
|
||||
export default async function QuoteManagementPage() {
|
||||
// 서버에서 초기 데이터 조회
|
||||
const result = await getQuotes({ perPage: 100 });
|
||||
const DEFAULT_PAGINATION = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 100,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export default function QuoteManagementPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getQuotes>>['data']>([]);
|
||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getQuotes({ perPage: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setPagination(result.pagination);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteManagementClient
|
||||
initialData={result.data}
|
||||
initialPagination={result.pagination}
|
||||
initialData={data}
|
||||
initialPagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AccountInfoClient } from '@/components/settings/AccountInfoManagement';
|
||||
import { getAccountInfo } from '@/components/settings/AccountInfoManagement/actions';
|
||||
import type { AccountInfo, TermsAgreement, MarketingConsent } from '@/components/settings/AccountInfoManagement/types';
|
||||
|
||||
export default async function AccountInfoPage() {
|
||||
const result = await getAccountInfo();
|
||||
const DEFAULT_ACCOUNT_INFO: AccountInfo = {
|
||||
id: '',
|
||||
email: '',
|
||||
profileImage: undefined,
|
||||
role: '',
|
||||
status: 'active',
|
||||
isTenantMaster: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
// 실패 시 빈 데이터로 렌더링 (클라이언트에서 에러 처리)
|
||||
const DEFAULT_MARKETING_CONSENT: MarketingConsent = {
|
||||
email: { agreed: false },
|
||||
sms: { agreed: false },
|
||||
};
|
||||
|
||||
export default function AccountInfoPage() {
|
||||
const [accountInfo, setAccountInfo] = useState<AccountInfo>(DEFAULT_ACCOUNT_INFO);
|
||||
const [termsAgreements, setTermsAgreements] = useState<TermsAgreement[]>([]);
|
||||
const [marketingConsent, setMarketingConsent] = useState<MarketingConsent>(DEFAULT_MARKETING_CONSENT);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getAccountInfo()
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setAccountInfo(result.data.accountInfo);
|
||||
setTermsAgreements(result.data.termsAgreements);
|
||||
setMarketingConsent(result.data.marketingConsent);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AccountInfoClient
|
||||
initialAccountInfo={{
|
||||
id: '',
|
||||
email: '',
|
||||
profileImage: undefined,
|
||||
role: '',
|
||||
status: 'active',
|
||||
isTenantMaster: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}}
|
||||
initialTermsAgreements={[]}
|
||||
initialMarketingConsent={{
|
||||
email: { agreed: false },
|
||||
sms: { agreed: false },
|
||||
}}
|
||||
error={result.error}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountInfoClient
|
||||
initialAccountInfo={result.data.accountInfo}
|
||||
initialTermsAgreements={result.data.termsAgreements}
|
||||
initialMarketingConsent={result.data.marketingConsent}
|
||||
initialAccountInfo={accountInfo}
|
||||
initialTermsAgreements={termsAgreements}
|
||||
initialMarketingConsent={marketingConsent}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NotificationSettingsManagement } from '@/components/settings/NotificationSettings';
|
||||
import { getNotificationSettings } from '@/components/settings/NotificationSettings/actions';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS } from '@/components/settings/NotificationSettings/types';
|
||||
import type { NotificationSettings } from '@/components/settings/NotificationSettings/types';
|
||||
|
||||
export default async function NotificationSettingsPage() {
|
||||
const result = await getNotificationSettings();
|
||||
export default function NotificationSettingsPage() {
|
||||
const [data, setData] = useState<NotificationSettings>(DEFAULT_NOTIFICATION_SETTINGS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return <NotificationSettingsManagement initialData={result.data} />;
|
||||
useEffect(() => {
|
||||
getNotificationSettings()
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <NotificationSettingsManagement initialData={data} />;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PermissionDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
export default function PermissionDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
return <PermissionDetailClient permissionId={id} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PopupList } from '@/components/settings/PopupManagement';
|
||||
import { getPopups } from '@/components/settings/PopupManagement/actions';
|
||||
import type { Popup } from '@/components/settings/PopupManagement/types';
|
||||
|
||||
export default async function PopupManagementPage() {
|
||||
const popups = await getPopups({ size: 100 });
|
||||
export default function PopupManagementPage() {
|
||||
const [data, setData] = useState<Popup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return <PopupList initialData={popups} />;
|
||||
}
|
||||
useEffect(() => {
|
||||
getPopups({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PopupList initialData={data} />;
|
||||
}
|
||||
@@ -1,8 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement';
|
||||
import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions';
|
||||
|
||||
export default async function SubscriptionPage() {
|
||||
const result = await getSubscriptionData();
|
||||
export default function SubscriptionPage() {
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getSubscriptionData>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return <SubscriptionManagement initialData={result.data} />;
|
||||
useEffect(() => {
|
||||
getSubscriptionData()
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SubscriptionManagement initialData={data} />;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -49,6 +51,7 @@ import type {
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
USAGE_TYPE_OPTIONS,
|
||||
} from './types';
|
||||
import { getCardTransactionList, getCardTransactionSummary, bulkUpdateAccountCode } from './actions';
|
||||
|
||||
@@ -90,6 +93,15 @@ export function CardTransactionInquiry({
|
||||
// 선택 필요 알림 다이얼로그
|
||||
const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false);
|
||||
|
||||
// 상세 모달 상태
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<CardTransaction | null>(null);
|
||||
const [detailFormData, setDetailFormData] = useState({
|
||||
memo: '',
|
||||
usageType: 'unset',
|
||||
});
|
||||
const [isDetailSaving, setIsDetailSaving] = useState(false);
|
||||
|
||||
// 날짜 범위 상태
|
||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
@@ -152,6 +164,40 @@ export function CardTransactionInquiry({
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 상세 모달 핸들러 =====
|
||||
const handleRowClick = useCallback((item: CardTransaction) => {
|
||||
setSelectedItem(item);
|
||||
setDetailFormData({
|
||||
memo: item.memo || '',
|
||||
usageType: item.usageType || 'unset',
|
||||
});
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handleDetailSave = useCallback(async () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
setIsDetailSaving(true);
|
||||
try {
|
||||
// TODO: API 호출로 상세 정보 저장
|
||||
// const result = await updateCardTransaction(selectedItem.id, detailFormData);
|
||||
|
||||
// 임시: 로컬 데이터 업데이트
|
||||
setData(prev => prev.map(item =>
|
||||
item.id === selectedItem.id
|
||||
? { ...item, memo: detailFormData.memo, usageType: detailFormData.usageType }
|
||||
: item
|
||||
));
|
||||
|
||||
setShowDetailModal(false);
|
||||
setSelectedItem(null);
|
||||
} catch (error) {
|
||||
console.error('[CardTransactionInquiry] handleDetailSave error:', error);
|
||||
} finally {
|
||||
setIsDetailSaving(false);
|
||||
}
|
||||
}, [selectedItem, detailFormData]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
@@ -269,6 +315,11 @@ export function CardTransactionInquiry({
|
||||
];
|
||||
}, [summary]);
|
||||
|
||||
// ===== 사용유형 라벨 변환 함수 =====
|
||||
const getUsageTypeLabel = useCallback((value: string) => {
|
||||
return USAGE_TYPE_OPTIONS.find(opt => opt.value === value)?.label || '미설정';
|
||||
}, []);
|
||||
|
||||
// ===== 테이블 컬럼 (체크박스/번호 없음) =====
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'card', label: '카드' },
|
||||
@@ -277,6 +328,7 @@ export function CardTransactionInquiry({
|
||||
{ key: 'usedAt', label: '사용일시' },
|
||||
{ key: 'merchantName', label: '가맹점명' },
|
||||
{ key: 'amount', label: '사용금액', className: 'text-right' },
|
||||
{ key: 'usageType', label: '사용유형' },
|
||||
], []);
|
||||
|
||||
// ===== 테이블 행 렌더링 =====
|
||||
@@ -286,7 +338,8 @@ export function CardTransactionInquiry({
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50"
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -306,9 +359,11 @@ export function CardTransactionInquiry({
|
||||
<TableCell className="text-right font-medium">
|
||||
{item.amount.toLocaleString()}
|
||||
</TableCell>
|
||||
{/* 사용유형 */}
|
||||
<TableCell>{getUsageTypeLabel(item.usageType)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection]);
|
||||
}, [selectedItems, toggleSelection, getUsageTypeLabel, handleRowClick]);
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
@@ -430,6 +485,7 @@ export function CardTransactionInquiry({
|
||||
<TableCell className="text-right font-bold">
|
||||
{totalAmount.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -519,6 +575,94 @@ export function CardTransactionInquiry({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 카드 내역 상세 모달 */}
|
||||
<Dialog open={showDetailModal} onOpenChange={setShowDetailModal}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>카드 내역 상세</DialogTitle>
|
||||
<DialogDescription>
|
||||
카드 사용 상세 내역을 등록합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedItem && (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="border rounded-lg p-4 bg-gray-50">
|
||||
<h4 className="font-medium text-gray-800 mb-4">기본 정보</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm text-gray-500">사용일시</Label>
|
||||
<p className="mt-1 text-sm font-medium">{selectedItem.usedAt}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-gray-500">카드</Label>
|
||||
<p className="mt-1 text-sm font-medium">{selectedItem.card} ({selectedItem.cardName})</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-gray-500">사용자</Label>
|
||||
<p className="mt-1 text-sm font-medium">{selectedItem.user}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-gray-500">사용금액</Label>
|
||||
<p className="mt-1 text-sm font-medium">{selectedItem.amount.toLocaleString()}원</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="detail-memo" className="text-sm text-gray-500">적요</Label>
|
||||
<Input
|
||||
id="detail-memo"
|
||||
value={detailFormData.memo}
|
||||
onChange={(e) => setDetailFormData(prev => ({ ...prev, memo: e.target.value }))}
|
||||
placeholder="적요"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-gray-500">가맹점</Label>
|
||||
<p className="mt-1 text-sm font-medium">{selectedItem.merchantName}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="detail-usage-type" className="text-sm text-gray-500">사용 유형</Label>
|
||||
<Select
|
||||
key={`usage-type-${detailFormData.usageType}`}
|
||||
value={detailFormData.usageType}
|
||||
onValueChange={(value) => setDetailFormData(prev => ({ ...prev, usageType: value }))}
|
||||
>
|
||||
<SelectTrigger id="detail-usage-type" className="mt-1">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{USAGE_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleDetailSave}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isDetailSaving}
|
||||
>
|
||||
{isDetailSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
'수정'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface CardTransaction {
|
||||
usedAt: string; // 사용일시
|
||||
merchantName: string; // 가맹점명
|
||||
amount: number; // 사용금액
|
||||
memo?: string; // 적요
|
||||
usageType: string; // 사용유형
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -25,6 +27,28 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'amountLow', label: '금액낮은순' },
|
||||
];
|
||||
|
||||
// ===== 사용유형 옵션 =====
|
||||
export const USAGE_TYPE_OPTIONS = [
|
||||
{ value: 'unset', label: '미설정' },
|
||||
{ value: 'welfare', label: '복리후생비' },
|
||||
{ value: 'entertainment', label: '접대비' },
|
||||
{ value: 'transportation', label: '여비교통비' },
|
||||
{ value: 'vehicle', label: '차량유지비' },
|
||||
{ value: 'supplies', label: '소모품비' },
|
||||
{ value: 'delivery', label: '운반비' },
|
||||
{ value: 'communication', label: '통신비' },
|
||||
{ value: 'printing', label: '도서인쇄비' },
|
||||
{ value: 'training', label: '교육훈련비' },
|
||||
{ value: 'insurance', label: '보험료' },
|
||||
{ value: 'advertising', label: '광고선전비' },
|
||||
{ value: 'membership', label: '회비' },
|
||||
{ value: 'commission', label: '지급수수료' },
|
||||
{ value: 'taxesAndDues', label: '세금과공과' },
|
||||
{ value: 'repair', label: '수선비' },
|
||||
{ value: 'rent', label: '임차료' },
|
||||
{ value: 'miscellaneous', label: '잡비' },
|
||||
];
|
||||
|
||||
// ===== 계정과목명 옵션 (상단 셀렉트) =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: 'unset', label: '미설정' },
|
||||
|
||||
@@ -281,6 +281,7 @@ const mockData: CEODashboardData = {
|
||||
],
|
||||
},
|
||||
],
|
||||
detailButtonPath: '/accounting/receivables-status',
|
||||
},
|
||||
debtCollection: {
|
||||
cards: [
|
||||
@@ -955,12 +956,40 @@ export function CEODashboard() {
|
||||
cm4: {
|
||||
title: '대표자 종합소득세 예상 가중 상세',
|
||||
summaryCards: [
|
||||
{ label: '합계', value: 3123000, unit: '원' },
|
||||
{ label: '전월 대비', value: '+12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '대표자 종합세 예상 가중', value: 3123000, unit: '원' },
|
||||
{ label: '추가 세금', value: '+12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '가지급금', value: '4.5억원' },
|
||||
{ label: '인정 이자', value: 6000000, unit: '원' },
|
||||
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
|
||||
],
|
||||
table: {
|
||||
comparisonSection: {
|
||||
leftBox: {
|
||||
title: '가지급금 인정이자가 반영된 종합소득세',
|
||||
items: [
|
||||
{ label: '현재 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
||||
{ label: '현재 적용 세율', value: '19%' },
|
||||
{ label: '현재 예상 세액', value: 10000000, unit: '원' },
|
||||
],
|
||||
borderColor: 'orange',
|
||||
},
|
||||
rightBox: {
|
||||
title: '가지급금 인정이자가 정리된 종합소득세',
|
||||
items: [
|
||||
{ label: '가지급금 정리 시 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
||||
{ label: '가지급금 정리 시 적용 세율', value: '19%' },
|
||||
{ label: '가지급금 정리 시 예상 세액', value: 10000000, unit: '원' },
|
||||
],
|
||||
borderColor: 'blue',
|
||||
},
|
||||
vsLabel: '종합소득세 예상 절감',
|
||||
vsValue: 3123000,
|
||||
vsSubLabel: '감소 세금 -12.5%',
|
||||
vsBreakdown: [
|
||||
{ label: '종합소득세', value: -2000000, unit: '원' },
|
||||
{ label: '지방소득세', value: -200000, unit: '원' },
|
||||
{ label: '4대 보험', value: -1000000, unit: '원' },
|
||||
],
|
||||
},
|
||||
referenceTable: {
|
||||
title: '종합소득세 과세표준 (2024년 기준)',
|
||||
columns: [
|
||||
{ key: 'bracket', label: '과세표준', align: 'left' },
|
||||
@@ -989,16 +1018,450 @@ export function CEODashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 접대비 클릭
|
||||
const handleEntertainmentClick = useCallback(() => {
|
||||
// TODO: 접대비 상세 팝업 열기
|
||||
console.log('접대비 클릭');
|
||||
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
|
||||
const handleEntertainmentCardClick = useCallback((cardId: string) => {
|
||||
// 접대비 상세 공통 모달 config (et2, et3, et4 공통)
|
||||
const entertainmentDetailConfig: DetailModalConfig = {
|
||||
title: '접대비 상세',
|
||||
summaryCards: [
|
||||
// 첫 번째 줄: 당해년도
|
||||
{ label: '당해년도 접대비 총한도', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 접대비 사용잔액', value: 0, unit: '원' },
|
||||
// 두 번째 줄: 분기별
|
||||
{ label: '1사분기 접대비 총한도', value: 3123000, unit: '원' },
|
||||
{ label: '1사분기 접대비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 접대비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 접대비 초과금액', value: 6000000, unit: '원' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 접대비 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 3500000 },
|
||||
{ name: '2월', value: 4200000 },
|
||||
{ name: '3월', value: 2300000 },
|
||||
{ name: '4월', value: 3800000 },
|
||||
{ name: '5월', value: 4500000 },
|
||||
{ name: '6월', value: 3200000 },
|
||||
{ name: '7월', value: 2800000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '사용자별 접대비 사용 비율',
|
||||
data: [
|
||||
{ name: '홍길동', value: 15000000, percentage: 53, color: '#60A5FA' },
|
||||
{ name: '김철수', value: 10000000, percentage: 31, color: '#34D399' },
|
||||
{ name: '이영희', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||
{ name: '기타', value: 2000000, percentage: 6, color: '#F87171' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '월별 접대비 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
|
||||
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'purpose', label: '사용용도', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
{ value: '김철수', label: '김철수' },
|
||||
{ value: '이영희', label: '이영희' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
// 접대비 손금한도 계산 - 기본한도 / 수입금액별 추가한도
|
||||
referenceTables: [
|
||||
{
|
||||
title: '접대비 손금한도 계산 - 기본한도',
|
||||
columns: [
|
||||
{ key: 'type', label: '구분', align: 'left' },
|
||||
{ key: 'limit', label: '기본한도', align: 'right' },
|
||||
],
|
||||
data: [
|
||||
{ type: '일반법인', limit: '3,600만원 (연 1,200만원)' },
|
||||
{ type: '중소기업', limit: '5,400만원 (연 3,600만원)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '수입금액별 추가한도',
|
||||
columns: [
|
||||
{ key: 'range', label: '수입금액', align: 'left' },
|
||||
{ key: 'rate', label: '적용률', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ range: '100억원 이하', rate: '0.3%' },
|
||||
{ range: '100억원 초과 ~ 500억원 이하', rate: '0.2%' },
|
||||
{ range: '500억원 초과', rate: '0.03%' },
|
||||
],
|
||||
},
|
||||
],
|
||||
// 접대비 계산
|
||||
calculationCards: {
|
||||
title: '접대비 계산',
|
||||
cards: [
|
||||
{ label: '기본한도', value: 36000000 },
|
||||
{ label: '추가한도', value: 91170000, operator: '+' },
|
||||
{ label: '접대비 손금한도', value: 127170000, operator: '=' },
|
||||
],
|
||||
},
|
||||
// 접대비 현황 (분기별)
|
||||
quarterlyTable: {
|
||||
title: '접대비 현황',
|
||||
rows: [
|
||||
{ label: '접대비 한도', q1: 31792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 127170000 },
|
||||
{ label: '접대비 사용', q1: 10000000, q2: 0, q3: 0, q4: 0, total: 10000000 },
|
||||
{ label: '접대비 잔여', q1: 21792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 117170000 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const cardConfigs: Record<string, DetailModalConfig> = {
|
||||
et1: {
|
||||
title: '당해 매출 상세',
|
||||
summaryCards: [
|
||||
{ label: '당해년도 매출', value: 600000000, unit: '원' },
|
||||
{ label: '전년 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '당월 매출', value: 6000000, unit: '원' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 매출 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 85000000 },
|
||||
{ name: '2월', value: 92000000 },
|
||||
{ name: '3월', value: 78000000 },
|
||||
{ name: '4월', value: 95000000 },
|
||||
{ name: '5월', value: 88000000 },
|
||||
{ name: '6월', value: 102000000 },
|
||||
{ name: '7월', value: 60000000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
horizontalBarChart: {
|
||||
title: '당해년도 거래처별 매출',
|
||||
data: [
|
||||
{ name: '(주)세우', value: 120000000 },
|
||||
{ name: '대한건설', value: 95000000 },
|
||||
{ name: '삼성테크', value: 78000000 },
|
||||
{ name: '현대상사', value: 65000000 },
|
||||
{ name: '기타', value: 42000000 },
|
||||
],
|
||||
color: '#60A5FA',
|
||||
},
|
||||
table: {
|
||||
title: '일별 매출 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '매출일', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'amount', label: '매출금액', align: 'right', format: 'currency' },
|
||||
{ key: 'type', label: '매출유형', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부품 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '공사 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'type',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '상품 매출', label: '상품 매출' },
|
||||
{ value: '부품 매출', label: '부품 매출' },
|
||||
{ value: '공사 매출', label: '공사 매출' },
|
||||
{ value: '임대 수익', label: '임대 수익' },
|
||||
{ value: '기타 매출', label: '기타 매출' },
|
||||
{ value: '미설정', label: '미설정' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// et2, et3, et4는 모두 동일한 접대비 상세 모달
|
||||
et2: entertainmentDetailConfig,
|
||||
et3: entertainmentDetailConfig,
|
||||
et4: entertainmentDetailConfig,
|
||||
};
|
||||
|
||||
const config = cardConfigs[cardId];
|
||||
if (config) {
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 부가세 클릭
|
||||
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
|
||||
const handleWelfareCardClick = useCallback(() => {
|
||||
// 계산 방식에 따른 조건부 calculationCards 생성
|
||||
const calculationType = dashboardSettings.welfare.calculationType;
|
||||
const calculationCards = calculationType === 'fixed'
|
||||
? {
|
||||
// 직원당 정액 금액/월 방식
|
||||
title: '복리후생비 계산',
|
||||
subtitle: '직원당 정액 금액/월 200,000원',
|
||||
cards: [
|
||||
{ label: '직원 수', value: 20, unit: '명' },
|
||||
{ label: '연간 직원당 월급 금액', value: 2400000, unit: '원', operator: '×' as const },
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 48000000, unit: '원', operator: '=' as const },
|
||||
],
|
||||
}
|
||||
: {
|
||||
// 연봉 총액 비율 방식
|
||||
title: '복리후생비 계산',
|
||||
subtitle: '연봉 총액 기준 비율 20.5%',
|
||||
cards: [
|
||||
{ label: '연봉 총액', value: 1000000000, unit: '원' },
|
||||
{ label: '비율', value: 20.5, unit: '%', operator: '×' as const },
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 205000000, unit: '원', operator: '=' as const },
|
||||
],
|
||||
};
|
||||
|
||||
const config: DetailModalConfig = {
|
||||
title: '복리후생비 상세',
|
||||
summaryCards: [
|
||||
// 1행: 당해년도 기준
|
||||
{ label: '당해년도 복리후생비 계정', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 한도', value: 600000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 사용', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 잔여한도', value: 0, unit: '원' },
|
||||
// 2행: 1사분기 기준
|
||||
{ label: '1사분기 복리후생비 총 한도', value: 3123000, unit: '원' },
|
||||
{ label: '1사분기 복리후생비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 복리후생비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 복리후생비 초과 금액', value: 6000000, unit: '원' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 복리후생비 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 1500000 },
|
||||
{ name: '2월', value: 1800000 },
|
||||
{ name: '3월', value: 2200000 },
|
||||
{ name: '4월', value: 1900000 },
|
||||
{ name: '5월', value: 2100000 },
|
||||
{ name: '6월', value: 1700000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '항목별 사용 비율',
|
||||
data: [
|
||||
{ name: '식비', value: 55000000, percentage: 55, color: '#FBBF24' },
|
||||
{ name: '건강검진', value: 25000000, percentage: 5, color: '#60A5FA' },
|
||||
{ name: '경조사비', value: 10000000, percentage: 10, color: '#F87171' },
|
||||
{ name: '기타', value: 10000000, percentage: 30, color: '#34D399' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 복리후생비 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '사용항목', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '식비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, usageType: '건강검진' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, usageType: '경조사비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, usageType: '기타' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, usageType: '식비' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'usageType',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '식비', label: '식비' },
|
||||
{ value: '건강검진', label: '건강검진' },
|
||||
{ value: '경조사비', label: '경조사비' },
|
||||
{ value: '기타', label: '기타' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
// 복리후생비 계산 (조건부 - calculationType에 따라)
|
||||
calculationCards,
|
||||
// 복리후생비 현황 (분기별 테이블)
|
||||
quarterlyTable: {
|
||||
title: '복리후생비 현황',
|
||||
rows: [
|
||||
{ label: '한도금액', q1: 12000000, q2: 12000000, q3: 12000000, q4: 12000000, total: 48000000 },
|
||||
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}, [dashboardSettings.welfare.calculationType]);
|
||||
|
||||
// 부가세 클릭 (모든 카드가 동일한 상세 모달)
|
||||
const handleVatClick = useCallback(() => {
|
||||
// TODO: 부가세 상세 팝업 열기
|
||||
console.log('부가세 클릭');
|
||||
const config: DetailModalConfig = {
|
||||
title: '예상 납부세액',
|
||||
summaryCards: [],
|
||||
// 세액 산출 내역 테이블
|
||||
referenceTable: {
|
||||
title: '2026년 1사분기 세액 산출 내역',
|
||||
columns: [
|
||||
{ key: 'category', label: '구분', align: 'center' },
|
||||
{ key: 'amount', label: '금액', align: 'right' },
|
||||
{ key: 'note', label: '비고', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ category: '매출세액', amount: '11,000,000', note: '과세매출 X 10%' },
|
||||
{ category: '매입세액', amount: '1,000,000', note: '공제대상 매입 X 10%' },
|
||||
{ category: '경감·공제세액', amount: '0', note: '해당없음' },
|
||||
],
|
||||
},
|
||||
// 예상 납부세액 계산
|
||||
calculationCards: {
|
||||
title: '예상 납부세액 계산',
|
||||
cards: [
|
||||
{ label: '매출세액', value: 11000000, unit: '원' },
|
||||
{ label: '매입세액', value: 1000000, unit: '원', operator: '-' },
|
||||
{ label: '경감·공제세액', value: 0, unit: '원', operator: '-' },
|
||||
{ label: '예상 납부세액', value: 10000000, unit: '원', operator: '=' },
|
||||
],
|
||||
},
|
||||
// 세금계산서 미발행/미수취 내역
|
||||
table: {
|
||||
title: '세금계산서 미발행/미수취 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'type', label: '구분', align: 'center' },
|
||||
{ key: 'issueDate', label: '발행일자', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
|
||||
{ key: 'invoiceStatus', label: '세금계산서 발행', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처1', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처2', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처3', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처4', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처5', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처6', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처7', vat: 11000000, invoiceStatus: '미발행' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'type',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '매출', label: '매출' },
|
||||
{ value: '매입', label: '매입' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'invoiceStatus',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '미발행', label: '미발행' },
|
||||
{ value: '미수취', label: '미수취' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'vat',
|
||||
},
|
||||
};
|
||||
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 캘린더 일정 클릭 (기존 일정 수정)
|
||||
@@ -1119,13 +1582,16 @@ export function CEODashboard() {
|
||||
{dashboardSettings.entertainment.enabled && (
|
||||
<EntertainmentSection
|
||||
data={data.entertainment}
|
||||
onClick={handleEntertainmentClick}
|
||||
onCardClick={handleEntertainmentCardClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 복리후생비 현황 */}
|
||||
{dashboardSettings.welfare.enabled && (
|
||||
<WelfareSection data={data.welfare} />
|
||||
<WelfareSection
|
||||
data={data.welfare}
|
||||
onCardClick={handleWelfareCardClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 미수금 현황 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
TableFilterConfig,
|
||||
ComparisonSectionConfig,
|
||||
ReferenceTableConfig,
|
||||
CalculationCardsConfig,
|
||||
QuarterlyTableConfig,
|
||||
} from '../types';
|
||||
|
||||
interface DetailModalProps {
|
||||
@@ -245,7 +247,7 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
|
||||
{/* VS 영역 */}
|
||||
<div className="flex flex-col items-center justify-center px-4">
|
||||
<span className="text-2xl font-bold text-gray-400 mb-2">VS</span>
|
||||
<div className="bg-red-50 rounded-lg px-4 py-2 text-center">
|
||||
<div className="bg-red-50 rounded-lg px-4 py-3 text-center min-w-[180px]">
|
||||
<p className="text-xs text-gray-600 mb-1">{config.vsLabel}</p>
|
||||
<p className="text-xl font-bold text-red-500">
|
||||
{typeof config.vsValue === 'number'
|
||||
@@ -255,6 +257,21 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
|
||||
{config.vsSubLabel && (
|
||||
<p className="text-xs text-gray-500 mt-1">{config.vsSubLabel}</p>
|
||||
)}
|
||||
{/* VS 세부 항목 */}
|
||||
{config.vsBreakdown && config.vsBreakdown.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-red-200 space-y-1">
|
||||
{config.vsBreakdown.map((item, index) => (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className="text-gray-600">{item.label}</span>
|
||||
<span className="font-medium text-gray-700">
|
||||
{typeof item.value === 'number'
|
||||
? formatCurrency(item.value) + (item.unit || '원')
|
||||
: item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,6 +301,105 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 계산 카드 섹션 컴포넌트 (접대비 계산 등)
|
||||
*/
|
||||
const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => {
|
||||
const isResultCard = (index: number, operator?: string) => {
|
||||
// '=' 연산자가 있는 카드는 결과 카드로 강조
|
||||
return operator === '=';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h4 className="font-medium text-gray-800">{config.title}</h4>
|
||||
{config.subtitle && (
|
||||
<span className="text-sm text-gray-500">{config.subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{config.cards.map((card, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
{/* 연산자 표시 (첫 번째 카드 제외) */}
|
||||
{index > 0 && card.operator && (
|
||||
<span className="text-3xl font-bold text-gray-400">
|
||||
{card.operator}
|
||||
</span>
|
||||
)}
|
||||
{/* 카드 */}
|
||||
<div className={cn(
|
||||
"rounded-lg p-5 min-w-[180px] text-center border",
|
||||
isResultCard(index, card.operator)
|
||||
? "bg-blue-50 border-blue-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
)}>
|
||||
<p className={cn(
|
||||
"text-sm mb-2",
|
||||
isResultCard(index, card.operator) ? "text-blue-600" : "text-gray-500"
|
||||
)}>
|
||||
{card.label}
|
||||
</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
isResultCard(index, card.operator) ? "text-blue-700" : "text-gray-900"
|
||||
)}>
|
||||
{formatCurrency(card.value)}{card.unit || '원'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 분기별 테이블 섹션 컴포넌트 (접대비 현황 등)
|
||||
*/
|
||||
const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
|
||||
const formatValue = (value: number | string | undefined): string => {
|
||||
if (value === undefined) return '-';
|
||||
if (typeof value === 'number') return formatCurrency(value);
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left">구분</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">1사분기</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">2사분기</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">3사분기</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">4사분기</th>
|
||||
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className="border-t border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{row.label}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q1)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q2)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q3)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q4)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 text-center font-medium">{formatValue(row.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 참조 테이블 컴포넌트 (필터 없는 정보성 테이블)
|
||||
*/
|
||||
@@ -612,13 +728,32 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
||||
<ComparisonSection config={config.comparisonSection} />
|
||||
)}
|
||||
|
||||
{/* 참조 테이블 영역 */}
|
||||
{/* 참조 테이블 영역 (단일 - 테이블 위에 표시) */}
|
||||
{config.referenceTable && (
|
||||
<ReferenceTableSection config={config.referenceTable} />
|
||||
)}
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
{/* 계산 카드 섹션 영역 (테이블 위에 표시) */}
|
||||
{config.calculationCards && (
|
||||
<CalculationCardsSection config={config.calculationCards} />
|
||||
)}
|
||||
|
||||
{/* 메인 테이블 영역 */}
|
||||
{config.table && <TableSection key={config.title} config={config.table} />}
|
||||
|
||||
{/* 참조 테이블 영역 (다중 - 테이블 아래 표시) */}
|
||||
{config.referenceTables && config.referenceTables.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{config.referenceTables.map((tableConfig, index) => (
|
||||
<ReferenceTableSection key={index} config={tableConfig} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분기별 테이블 영역 */}
|
||||
{config.quarterlyTable && (
|
||||
<QuarterlyTableSection config={config.quarterlyTable} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -11,10 +11,7 @@ interface DailyReportSectionProps {
|
||||
|
||||
export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
|
||||
return (
|
||||
<Card
|
||||
className={onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<SectionTitle title="일일 일보" badge="info" />
|
||||
@@ -23,7 +20,7 @@ export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem key={card.id} card={card} />
|
||||
<AmountCardItem key={card.id} card={card} onClick={onClick} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import type { EntertainmentData } from '../types';
|
||||
|
||||
interface EntertainmentSectionProps {
|
||||
data: EntertainmentData;
|
||||
onClick?: () => void;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
export function EntertainmentSection({ data, onClick }: EntertainmentSectionProps) {
|
||||
export function EntertainmentSection({ data, onCardClick }: EntertainmentSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -20,7 +20,7 @@ export function EntertainmentSection({ data, onClick }: EntertainmentSectionProp
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={onClick}
|
||||
onClick={() => onCardClick?.(card.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,10 @@ import type { WelfareData } from '../types';
|
||||
|
||||
interface WelfareSectionProps {
|
||||
data: WelfareData;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
export function WelfareSection({ data }: WelfareSectionProps) {
|
||||
export function WelfareSection({ data, onCardClick }: WelfareSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -16,7 +17,11 @@ export function WelfareSection({ data }: WelfareSectionProps) {
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem key={card.id} card={card} />
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onClick={onCardClick ? () => onCardClick(card.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -326,6 +326,13 @@ export interface ComparisonBoxConfig {
|
||||
borderColor: 'orange' | 'blue';
|
||||
}
|
||||
|
||||
// VS 중앙 세부 항목 타입
|
||||
export interface VsBreakdownItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
// VS 비교 섹션 설정 타입
|
||||
export interface ComparisonSectionConfig {
|
||||
leftBox: ComparisonBoxConfig;
|
||||
@@ -333,6 +340,7 @@ export interface ComparisonSectionConfig {
|
||||
vsLabel: string;
|
||||
vsValue: string | number;
|
||||
vsSubLabel?: string;
|
||||
vsBreakdown?: VsBreakdownItem[]; // VS 중앙에 표시할 세부 항목들
|
||||
}
|
||||
|
||||
// 참조 테이블 설정 타입 (필터 없는 정보성 테이블)
|
||||
@@ -342,6 +350,37 @@ export interface ReferenceTableConfig {
|
||||
data: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
// 계산 카드 아이템 타입 (접대비 계산 등)
|
||||
export interface CalculationCardItem {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
operator?: '+' | '=' | '-' | '×'; // 연산자 표시
|
||||
}
|
||||
|
||||
// 계산 카드 섹션 설정 타입
|
||||
export interface CalculationCardsConfig {
|
||||
title: string;
|
||||
subtitle?: string; // 서브타이틀 (예: "직원당 정액 금액/월 200,000원")
|
||||
cards: CalculationCardItem[];
|
||||
}
|
||||
|
||||
// 분기별 테이블 행 타입
|
||||
export interface QuarterlyTableRow {
|
||||
label: string;
|
||||
q1?: number | string;
|
||||
q2?: number | string;
|
||||
q3?: number | string;
|
||||
q4?: number | string;
|
||||
total?: number | string;
|
||||
}
|
||||
|
||||
// 분기별 테이블 설정 타입
|
||||
export interface QuarterlyTableConfig {
|
||||
title: string;
|
||||
rows: QuarterlyTableRow[];
|
||||
}
|
||||
|
||||
// 상세 모달 전체 설정 타입
|
||||
export interface DetailModalConfig {
|
||||
title: string;
|
||||
@@ -351,6 +390,9 @@ export interface DetailModalConfig {
|
||||
horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
|
||||
comparisonSection?: ComparisonSectionConfig; // VS 비교 섹션
|
||||
referenceTable?: ReferenceTableConfig; // 참조 테이블 (필터 없음)
|
||||
referenceTables?: ReferenceTableConfig[]; // 다중 참조 테이블
|
||||
calculationCards?: CalculationCardsConfig; // 계산 카드 섹션
|
||||
quarterlyTable?: QuarterlyTableConfig; // 분기별 테이블
|
||||
table?: TableConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 이력 조회
|
||||
*/
|
||||
|
||||
@@ -393,8 +393,8 @@ export function CompanyInfoManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 담당자명 / 담당자 연락처 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 담당자명 / 담당자 연락처 - 임시 주석처리 (추후 사용 가능) */}
|
||||
{/* <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="managerName">담당자명</Label>
|
||||
<Input
|
||||
@@ -415,7 +415,7 @@ export function CompanyInfoManagement() {
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 사업자등록증 / 사업자등록번호 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 알림설정 항목 설정 모달
|
||||
*
|
||||
* 각 알림 카테고리와 항목의 표시/숨김을 설정합니다.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { ItemVisibilitySettings } from './types';
|
||||
|
||||
interface ItemSettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: ItemVisibilitySettings;
|
||||
onSave: (settings: ItemVisibilitySettings) => void;
|
||||
}
|
||||
|
||||
// 카테고리 섹션 컴포넌트
|
||||
interface CategorySectionProps {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function CategorySection({ title, enabled, onEnabledChange, children }: CategorySectionProps) {
|
||||
return (
|
||||
<div className="bg-gray-100 rounded-lg overflow-hidden">
|
||||
{/* 카테고리 헤더 */}
|
||||
<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}
|
||||
className="data-[state=checked]:bg-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{/* 하위 항목 */}
|
||||
<div className="bg-gray-50 px-4 py-2 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 항목 행 컴포넌트
|
||||
interface ItemRowProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ItemRow({ label, checked, onChange, disabled }: ItemRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-gray-600 text-sm">{label}</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
className="data-[state=checked]:bg-blue-500 scale-90"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemSettingsDialog({ isOpen, onClose, settings, onSave }: ItemSettingsDialogProps) {
|
||||
const [localSettings, setLocalSettings] = useState<ItemVisibilitySettings>(settings);
|
||||
|
||||
// 모달 열릴 때 설정 동기화
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (open) {
|
||||
setLocalSettings(settings);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [settings, onClose]);
|
||||
|
||||
// 카테고리 전체 토글
|
||||
const handleCategoryToggle = useCallback((
|
||||
category: keyof ItemVisibilitySettings,
|
||||
enabled: boolean
|
||||
) => {
|
||||
setLocalSettings(prev => {
|
||||
const categorySettings = prev[category];
|
||||
const updatedCategory: Record<string, boolean> = { enabled };
|
||||
|
||||
// 모든 하위 항목도 같이 토글
|
||||
Object.keys(categorySettings).forEach(key => {
|
||||
if (key !== 'enabled') {
|
||||
updatedCategory[key] = enabled;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[category]: updatedCategory as typeof categorySettings,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 개별 항목 토글
|
||||
const handleItemToggle = useCallback((
|
||||
category: keyof ItemVisibilitySettings,
|
||||
item: string,
|
||||
checked: boolean
|
||||
) => {
|
||||
setLocalSettings(prev => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...prev[category],
|
||||
[item]: checked,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(localSettings);
|
||||
onClose();
|
||||
}, [localSettings, onSave, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<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-white z-10 px-4 py-3 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-gray-900 font-medium">항목 설정</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* 공지 알림 */}
|
||||
<CategorySection
|
||||
title="공지 알림"
|
||||
enabled={localSettings.notice.enabled}
|
||||
onEnabledChange={(enabled) => handleCategoryToggle('notice', enabled)}
|
||||
>
|
||||
<ItemRow
|
||||
label="공지사항 알림"
|
||||
checked={localSettings.notice.notice}
|
||||
onChange={(checked) => handleItemToggle('notice', 'notice', checked)}
|
||||
disabled={!localSettings.notice.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="이벤트 알림"
|
||||
checked={localSettings.notice.event}
|
||||
onChange={(checked) => handleItemToggle('notice', 'event', checked)}
|
||||
disabled={!localSettings.notice.enabled}
|
||||
/>
|
||||
</CategorySection>
|
||||
|
||||
{/* 일정 알림 */}
|
||||
<CategorySection
|
||||
title="일정 알림"
|
||||
enabled={localSettings.schedule.enabled}
|
||||
onEnabledChange={(enabled) => handleCategoryToggle('schedule', enabled)}
|
||||
>
|
||||
<ItemRow
|
||||
label="부가세 신고 알림"
|
||||
checked={localSettings.schedule.vatReport}
|
||||
onChange={(checked) => handleItemToggle('schedule', 'vatReport', checked)}
|
||||
disabled={!localSettings.schedule.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="종합소득세 신고 알림"
|
||||
checked={localSettings.schedule.incomeTaxReport}
|
||||
onChange={(checked) => handleItemToggle('schedule', 'incomeTaxReport', checked)}
|
||||
disabled={!localSettings.schedule.enabled}
|
||||
/>
|
||||
</CategorySection>
|
||||
|
||||
{/* 거래처 알림 */}
|
||||
<CategorySection
|
||||
title="거래처 알림"
|
||||
enabled={localSettings.vendor.enabled}
|
||||
onEnabledChange={(enabled) => handleCategoryToggle('vendor', enabled)}
|
||||
>
|
||||
<ItemRow
|
||||
label="신규 업체 등록 알림"
|
||||
checked={localSettings.vendor.newVendor}
|
||||
onChange={(checked) => handleItemToggle('vendor', 'newVendor', checked)}
|
||||
disabled={!localSettings.vendor.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="신용등급 알림"
|
||||
checked={localSettings.vendor.creditRating}
|
||||
onChange={(checked) => handleItemToggle('vendor', 'creditRating', checked)}
|
||||
disabled={!localSettings.vendor.enabled}
|
||||
/>
|
||||
</CategorySection>
|
||||
|
||||
{/* 근태 알림 */}
|
||||
<CategorySection
|
||||
title="근태 알림"
|
||||
enabled={localSettings.attendance.enabled}
|
||||
onEnabledChange={(enabled) => handleCategoryToggle('attendance', enabled)}
|
||||
>
|
||||
<ItemRow
|
||||
label="연차 알림"
|
||||
checked={localSettings.attendance.annualLeave}
|
||||
onChange={(checked) => handleItemToggle('attendance', 'annualLeave', checked)}
|
||||
disabled={!localSettings.attendance.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="출근 알림"
|
||||
checked={localSettings.attendance.clockIn}
|
||||
onChange={(checked) => handleItemToggle('attendance', 'clockIn', checked)}
|
||||
disabled={!localSettings.attendance.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="지각 알림"
|
||||
checked={localSettings.attendance.late}
|
||||
onChange={(checked) => handleItemToggle('attendance', 'late', checked)}
|
||||
disabled={!localSettings.attendance.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="결근 알림"
|
||||
checked={localSettings.attendance.absent}
|
||||
onChange={(checked) => handleItemToggle('attendance', 'absent', checked)}
|
||||
disabled={!localSettings.attendance.enabled}
|
||||
/>
|
||||
</CategorySection>
|
||||
|
||||
{/* 수주/발주 알림 */}
|
||||
<CategorySection
|
||||
title="수주/발주 알림"
|
||||
enabled={localSettings.order.enabled}
|
||||
onEnabledChange={(enabled) => handleCategoryToggle('order', enabled)}
|
||||
>
|
||||
<ItemRow
|
||||
label="수주 알림"
|
||||
checked={localSettings.order.salesOrder}
|
||||
onChange={(checked) => handleItemToggle('order', 'salesOrder', checked)}
|
||||
disabled={!localSettings.order.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="발주 알림"
|
||||
checked={localSettings.order.purchaseOrder}
|
||||
onChange={(checked) => handleItemToggle('order', 'purchaseOrder', checked)}
|
||||
disabled={!localSettings.order.enabled}
|
||||
/>
|
||||
</CategorySection>
|
||||
|
||||
{/* 전자결재 알림 */}
|
||||
<CategorySection
|
||||
title="전자결재 알림"
|
||||
enabled={localSettings.approval.enabled}
|
||||
onEnabledChange={(enabled) => handleCategoryToggle('approval', enabled)}
|
||||
>
|
||||
<ItemRow
|
||||
label="결재요청 알림"
|
||||
checked={localSettings.approval.approvalRequest}
|
||||
onChange={(checked) => handleItemToggle('approval', 'approvalRequest', checked)}
|
||||
disabled={!localSettings.approval.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="기안 > 승인 알림"
|
||||
checked={localSettings.approval.draftApproved}
|
||||
onChange={(checked) => handleItemToggle('approval', 'draftApproved', checked)}
|
||||
disabled={!localSettings.approval.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="기안 > 반려 알림"
|
||||
checked={localSettings.approval.draftRejected}
|
||||
onChange={(checked) => handleItemToggle('approval', 'draftRejected', checked)}
|
||||
disabled={!localSettings.approval.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="기안 > 완료 알림"
|
||||
checked={localSettings.approval.draftCompleted}
|
||||
onChange={(checked) => handleItemToggle('approval', 'draftCompleted', checked)}
|
||||
disabled={!localSettings.approval.enabled}
|
||||
/>
|
||||
</CategorySection>
|
||||
|
||||
{/* 생산 알림 */}
|
||||
<CategorySection
|
||||
title="생산 알림"
|
||||
enabled={localSettings.production.enabled}
|
||||
onEnabledChange={(enabled) => handleCategoryToggle('production', enabled)}
|
||||
>
|
||||
<ItemRow
|
||||
label="안전재고 알림"
|
||||
checked={localSettings.production.safetyStock}
|
||||
onChange={(checked) => handleItemToggle('production', 'safetyStock', checked)}
|
||||
disabled={!localSettings.production.enabled}
|
||||
/>
|
||||
<ItemRow
|
||||
label="생산완료 알림"
|
||||
checked={localSettings.production.productionComplete}
|
||||
onChange={(checked) => handleItemToggle('production', 'productionComplete', checked)}
|
||||
disabled={!localSettings.production.enabled}
|
||||
/>
|
||||
</CategorySection>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<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-100 border-gray-300 text-gray-700 hover:bg-gray-200 min-w-[80px] rounded-full"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-gray-500 text-white hover:bg-gray-600 min-w-[80px] rounded-full"
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,13 @@
|
||||
* 알림설정 페이지
|
||||
*
|
||||
* 각 알림 유형별로 ON/OFF 토글, 알림 소리 선택, 이메일 알림 체크박스를 제공합니다.
|
||||
* 항목 설정 기능으로 표시할 알림 카테고리/항목을 선택할 수 있습니다.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Bell, Save, Play } from 'lucide-react';
|
||||
import { Bell, Save, Play, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -22,9 +23,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import type { NotificationSettings, NotificationItem, SoundType } from './types';
|
||||
import { SOUND_OPTIONS } from './types';
|
||||
import type { NotificationSettings, NotificationItem, SoundType, ItemVisibilitySettings } from './types';
|
||||
import { SOUND_OPTIONS, DEFAULT_ITEM_VISIBILITY } from './types';
|
||||
import { saveNotificationSettings } from './actions';
|
||||
import { ItemSettingsDialog } from './ItemSettingsDialog';
|
||||
|
||||
// 미리듣기 함수
|
||||
function playPreviewSound(soundType: SoundType) {
|
||||
@@ -153,9 +155,28 @@ interface NotificationSettingsManagementProps {
|
||||
initialData: NotificationSettings;
|
||||
}
|
||||
|
||||
const ITEM_VISIBILITY_STORAGE_KEY = 'notification-item-visibility';
|
||||
|
||||
export function NotificationSettingsManagement({ initialData }: NotificationSettingsManagementProps) {
|
||||
const [settings, setSettings] = useState<NotificationSettings>(initialData);
|
||||
|
||||
// 항목 설정 (표시/숨김)
|
||||
const [itemVisibility, setItemVisibility] = useState<ItemVisibilitySettings>(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_ITEM_VISIBILITY;
|
||||
const saved = localStorage.getItem(ITEM_VISIBILITY_STORAGE_KEY);
|
||||
return saved ? JSON.parse(saved) : DEFAULT_ITEM_VISIBILITY;
|
||||
});
|
||||
|
||||
// 항목 설정 모달 상태
|
||||
const [isItemSettingsOpen, setIsItemSettingsOpen] = useState(false);
|
||||
|
||||
// 항목 설정 저장
|
||||
const handleItemVisibilitySave = useCallback((newSettings: ItemVisibilitySettings) => {
|
||||
setItemVisibility(newSettings);
|
||||
localStorage.setItem(ITEM_VISIBILITY_STORAGE_KEY, JSON.stringify(newSettings));
|
||||
toast.success('항목 설정이 저장되었습니다.');
|
||||
}, []);
|
||||
|
||||
// 공지 알림 핸들러
|
||||
const handleNoticeEnabledChange = (enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
@@ -342,185 +363,245 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
|
||||
icon={Bell}
|
||||
/>
|
||||
|
||||
{/* 상단 버튼 영역 */}
|
||||
<div className="flex justify-end gap-2 mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsItemSettingsOpen(true)}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white border-orange-500"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
항목 설정
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 공지 알림 */}
|
||||
<NotificationSection
|
||||
title="공지 알림"
|
||||
enabled={settings.notice.enabled}
|
||||
onEnabledChange={handleNoticeEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="공지사항 알림"
|
||||
item={settings.notice.notice}
|
||||
onChange={(item) => handleNoticeItemChange('notice', item)}
|
||||
disabled={!settings.notice.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="이벤트 알림"
|
||||
item={settings.notice.event}
|
||||
onChange={(item) => handleNoticeItemChange('event', item)}
|
||||
disabled={!settings.notice.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
{itemVisibility.notice.enabled && (
|
||||
<NotificationSection
|
||||
title="공지 알림"
|
||||
enabled={settings.notice.enabled}
|
||||
onEnabledChange={handleNoticeEnabledChange}
|
||||
>
|
||||
{itemVisibility.notice.notice && (
|
||||
<NotificationItemRow
|
||||
label="공지사항 알림"
|
||||
item={settings.notice.notice}
|
||||
onChange={(item) => handleNoticeItemChange('notice', item)}
|
||||
disabled={!settings.notice.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.notice.event && (
|
||||
<NotificationItemRow
|
||||
label="이벤트 알림"
|
||||
item={settings.notice.event}
|
||||
onChange={(item) => handleNoticeItemChange('event', item)}
|
||||
disabled={!settings.notice.enabled}
|
||||
/>
|
||||
)}
|
||||
</NotificationSection>
|
||||
)}
|
||||
|
||||
{/* 일정 알림 */}
|
||||
<NotificationSection
|
||||
title="일정 알림"
|
||||
enabled={settings.schedule.enabled}
|
||||
onEnabledChange={handleScheduleEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="부가세 신고 알림"
|
||||
item={settings.schedule.vatReport}
|
||||
onChange={(item) => handleScheduleItemChange('vatReport', item)}
|
||||
disabled={!settings.schedule.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="종합소득세 신고 알림"
|
||||
item={settings.schedule.incomeTaxReport}
|
||||
onChange={(item) => handleScheduleItemChange('incomeTaxReport', item)}
|
||||
disabled={!settings.schedule.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
{itemVisibility.schedule.enabled && (
|
||||
<NotificationSection
|
||||
title="일정 알림"
|
||||
enabled={settings.schedule.enabled}
|
||||
onEnabledChange={handleScheduleEnabledChange}
|
||||
>
|
||||
{itemVisibility.schedule.vatReport && (
|
||||
<NotificationItemRow
|
||||
label="부가세 신고 알림"
|
||||
item={settings.schedule.vatReport}
|
||||
onChange={(item) => handleScheduleItemChange('vatReport', item)}
|
||||
disabled={!settings.schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.schedule.incomeTaxReport && (
|
||||
<NotificationItemRow
|
||||
label="종합소득세 신고 알림"
|
||||
item={settings.schedule.incomeTaxReport}
|
||||
onChange={(item) => handleScheduleItemChange('incomeTaxReport', item)}
|
||||
disabled={!settings.schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
</NotificationSection>
|
||||
)}
|
||||
|
||||
{/* 거래처 알림 */}
|
||||
<NotificationSection
|
||||
title="거래처 알림"
|
||||
enabled={settings.vendor.enabled}
|
||||
onEnabledChange={handleVendorEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="신규 업체 등록 알림"
|
||||
item={settings.vendor.newVendor}
|
||||
onChange={(item) => handleVendorItemChange('newVendor', item)}
|
||||
disabled={!settings.vendor.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="신용등급 등록 알림"
|
||||
item={settings.vendor.creditRating}
|
||||
onChange={(item) => handleVendorItemChange('creditRating', item)}
|
||||
disabled={!settings.vendor.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
{itemVisibility.vendor.enabled && (
|
||||
<NotificationSection
|
||||
title="거래처 알림"
|
||||
enabled={settings.vendor.enabled}
|
||||
onEnabledChange={handleVendorEnabledChange}
|
||||
>
|
||||
{itemVisibility.vendor.newVendor && (
|
||||
<NotificationItemRow
|
||||
label="신규 업체 등록 알림"
|
||||
item={settings.vendor.newVendor}
|
||||
onChange={(item) => handleVendorItemChange('newVendor', item)}
|
||||
disabled={!settings.vendor.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.vendor.creditRating && (
|
||||
<NotificationItemRow
|
||||
label="신용등급 등록 알림"
|
||||
item={settings.vendor.creditRating}
|
||||
onChange={(item) => handleVendorItemChange('creditRating', item)}
|
||||
disabled={!settings.vendor.enabled}
|
||||
/>
|
||||
)}
|
||||
</NotificationSection>
|
||||
)}
|
||||
|
||||
{/* 근태 알림 */}
|
||||
<NotificationSection
|
||||
title="근태 알림"
|
||||
enabled={settings.attendance.enabled}
|
||||
onEnabledChange={handleAttendanceEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="연차 알림"
|
||||
item={settings.attendance.annualLeave}
|
||||
onChange={(item) => handleAttendanceItemChange('annualLeave', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="출근 알림"
|
||||
item={settings.attendance.clockIn}
|
||||
onChange={(item) => handleAttendanceItemChange('clockIn', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="지각 알림"
|
||||
item={settings.attendance.late}
|
||||
onChange={(item) => handleAttendanceItemChange('late', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="결근 알림"
|
||||
item={settings.attendance.absent}
|
||||
onChange={(item) => handleAttendanceItemChange('absent', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
{itemVisibility.attendance.enabled && (
|
||||
<NotificationSection
|
||||
title="근태 알림"
|
||||
enabled={settings.attendance.enabled}
|
||||
onEnabledChange={handleAttendanceEnabledChange}
|
||||
>
|
||||
{itemVisibility.attendance.annualLeave && (
|
||||
<NotificationItemRow
|
||||
label="연차 알림"
|
||||
item={settings.attendance.annualLeave}
|
||||
onChange={(item) => handleAttendanceItemChange('annualLeave', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.attendance.clockIn && (
|
||||
<NotificationItemRow
|
||||
label="출근 알림"
|
||||
item={settings.attendance.clockIn}
|
||||
onChange={(item) => handleAttendanceItemChange('clockIn', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.attendance.late && (
|
||||
<NotificationItemRow
|
||||
label="지각 알림"
|
||||
item={settings.attendance.late}
|
||||
onChange={(item) => handleAttendanceItemChange('late', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.attendance.absent && (
|
||||
<NotificationItemRow
|
||||
label="결근 알림"
|
||||
item={settings.attendance.absent}
|
||||
onChange={(item) => handleAttendanceItemChange('absent', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
)}
|
||||
</NotificationSection>
|
||||
)}
|
||||
|
||||
{/* 수주/발주 알림 */}
|
||||
<NotificationSection
|
||||
title="수주/발주 알림"
|
||||
enabled={settings.order.enabled}
|
||||
onEnabledChange={handleOrderEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="수주 등록 알림"
|
||||
item={settings.order.salesOrder}
|
||||
onChange={(item) => handleOrderItemChange('salesOrder', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="발주 알림"
|
||||
item={settings.order.purchaseOrder}
|
||||
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="결재요청 알림"
|
||||
item={settings.order.approvalRequest}
|
||||
onChange={(item) => handleOrderItemChange('approvalRequest', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
{itemVisibility.order.enabled && (
|
||||
<NotificationSection
|
||||
title="수주/발주 알림"
|
||||
enabled={settings.order.enabled}
|
||||
onEnabledChange={handleOrderEnabledChange}
|
||||
>
|
||||
{itemVisibility.order.salesOrder && (
|
||||
<NotificationItemRow
|
||||
label="수주 등록 알림"
|
||||
item={settings.order.salesOrder}
|
||||
onChange={(item) => handleOrderItemChange('salesOrder', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.order.purchaseOrder && (
|
||||
<NotificationItemRow
|
||||
label="발주 알림"
|
||||
item={settings.order.purchaseOrder}
|
||||
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
)}
|
||||
</NotificationSection>
|
||||
)}
|
||||
|
||||
{/* 전자결재 알림 */}
|
||||
<NotificationSection
|
||||
title="전자결재 알림"
|
||||
enabled={settings.approval.enabled}
|
||||
onEnabledChange={handleApprovalEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="결재요청 알림"
|
||||
item={settings.approval.approvalRequest}
|
||||
onChange={(item) => handleApprovalItemChange('approvalRequest', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="기안 > 승인 알림"
|
||||
item={settings.approval.draftApproved}
|
||||
onChange={(item) => handleApprovalItemChange('draftApproved', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="기안 > 반려 알림"
|
||||
item={settings.approval.draftRejected}
|
||||
onChange={(item) => handleApprovalItemChange('draftRejected', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="기안 > 완료 알림"
|
||||
item={settings.approval.draftCompleted}
|
||||
onChange={(item) => handleApprovalItemChange('draftCompleted', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
{itemVisibility.approval.enabled && (
|
||||
<NotificationSection
|
||||
title="전자결재 알림"
|
||||
enabled={settings.approval.enabled}
|
||||
onEnabledChange={handleApprovalEnabledChange}
|
||||
>
|
||||
{itemVisibility.approval.approvalRequest && (
|
||||
<NotificationItemRow
|
||||
label="결재요청 알림"
|
||||
item={settings.approval.approvalRequest}
|
||||
onChange={(item) => handleApprovalItemChange('approvalRequest', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.approval.draftApproved && (
|
||||
<NotificationItemRow
|
||||
label="기안 > 승인 알림"
|
||||
item={settings.approval.draftApproved}
|
||||
onChange={(item) => handleApprovalItemChange('draftApproved', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.approval.draftRejected && (
|
||||
<NotificationItemRow
|
||||
label="기안 > 반려 알림"
|
||||
item={settings.approval.draftRejected}
|
||||
onChange={(item) => handleApprovalItemChange('draftRejected', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.approval.draftCompleted && (
|
||||
<NotificationItemRow
|
||||
label="기안 > 완료 알림"
|
||||
item={settings.approval.draftCompleted}
|
||||
onChange={(item) => handleApprovalItemChange('draftCompleted', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
)}
|
||||
</NotificationSection>
|
||||
)}
|
||||
|
||||
{/* 생산 알림 */}
|
||||
<NotificationSection
|
||||
title="생산 알림"
|
||||
enabled={settings.production.enabled}
|
||||
onEnabledChange={handleProductionEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="안전재고 알림"
|
||||
item={settings.production.safetyStock}
|
||||
onChange={(item) => handleProductionItemChange('safetyStock', item)}
|
||||
disabled={!settings.production.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="생산완료 알림"
|
||||
item={settings.production.productionComplete}
|
||||
onChange={(item) => handleProductionItemChange('productionComplete', item)}
|
||||
disabled={!settings.production.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={handleSave} size="lg">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
{itemVisibility.production.enabled && (
|
||||
<NotificationSection
|
||||
title="생산 알림"
|
||||
enabled={settings.production.enabled}
|
||||
onEnabledChange={handleProductionEnabledChange}
|
||||
>
|
||||
{itemVisibility.production.safetyStock && (
|
||||
<NotificationItemRow
|
||||
label="안전재고 알림"
|
||||
item={settings.production.safetyStock}
|
||||
onChange={(item) => handleProductionItemChange('safetyStock', item)}
|
||||
disabled={!settings.production.enabled}
|
||||
/>
|
||||
)}
|
||||
{itemVisibility.production.productionComplete && (
|
||||
<NotificationItemRow
|
||||
label="생산완료 알림"
|
||||
item={settings.production.productionComplete}
|
||||
onChange={(item) => handleProductionItemChange('productionComplete', item)}
|
||||
disabled={!settings.production.enabled}
|
||||
/>
|
||||
)}
|
||||
</NotificationSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 항목 설정 모달 */}
|
||||
<ItemSettingsDialog
|
||||
isOpen={isItemSettingsOpen}
|
||||
onClose={() => setIsItemSettingsOpen(false)}
|
||||
settings={itemVisibility}
|
||||
onSave={handleItemVisibilitySave}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,72 @@ export interface NotificationSettings {
|
||||
production: ProductionNotificationSettings;
|
||||
}
|
||||
|
||||
// ===== 항목 설정 (표시/숨김) 타입 =====
|
||||
|
||||
// 공지 알림 항목 설정
|
||||
export interface NoticeItemVisibility {
|
||||
enabled: boolean;
|
||||
notice: boolean; // 공지사항 알림
|
||||
event: boolean; // 이벤트 알림
|
||||
}
|
||||
|
||||
// 일정 알림 항목 설정
|
||||
export interface ScheduleItemVisibility {
|
||||
enabled: boolean;
|
||||
vatReport: boolean; // 부가세 신고 알림
|
||||
incomeTaxReport: boolean; // 종합소득세 신고 알림
|
||||
}
|
||||
|
||||
// 거래처 알림 항목 설정
|
||||
export interface VendorItemVisibility {
|
||||
enabled: boolean;
|
||||
newVendor: boolean; // 신규 업체 등록 알림
|
||||
creditRating: boolean; // 신용등급 알림
|
||||
}
|
||||
|
||||
// 근태 알림 항목 설정
|
||||
export interface AttendanceItemVisibility {
|
||||
enabled: boolean;
|
||||
annualLeave: boolean; // 연차 알림
|
||||
clockIn: boolean; // 출근 알림
|
||||
late: boolean; // 지각 알림
|
||||
absent: boolean; // 결근 알림
|
||||
}
|
||||
|
||||
// 수주/발주 알림 항목 설정
|
||||
export interface OrderItemVisibility {
|
||||
enabled: boolean;
|
||||
salesOrder: boolean; // 수주 알림
|
||||
purchaseOrder: boolean; // 발주 알림
|
||||
}
|
||||
|
||||
// 전자결재 알림 항목 설정
|
||||
export interface ApprovalItemVisibility {
|
||||
enabled: boolean;
|
||||
approvalRequest: boolean; // 결재요청 알림
|
||||
draftApproved: boolean; // 기안 > 승인 알림
|
||||
draftRejected: boolean; // 기안 > 반려 알림
|
||||
draftCompleted: boolean; // 기안 > 완료 알림
|
||||
}
|
||||
|
||||
// 생산 알림 항목 설정
|
||||
export interface ProductionItemVisibility {
|
||||
enabled: boolean;
|
||||
safetyStock: boolean; // 안전재고 알림
|
||||
productionComplete: boolean; // 생산완료 알림
|
||||
}
|
||||
|
||||
// 전체 항목 설정
|
||||
export interface ItemVisibilitySettings {
|
||||
notice: NoticeItemVisibility;
|
||||
schedule: ScheduleItemVisibility;
|
||||
vendor: VendorItemVisibility;
|
||||
attendance: AttendanceItemVisibility;
|
||||
order: OrderItemVisibility;
|
||||
approval: ApprovalItemVisibility;
|
||||
production: ProductionItemVisibility;
|
||||
}
|
||||
|
||||
// 기본값
|
||||
export const DEFAULT_NOTIFICATION_ITEM: NotificationItem = {
|
||||
enabled: false,
|
||||
@@ -160,4 +226,47 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||
safetyStock: { enabled: false, email: false, soundType: 'default' },
|
||||
productionComplete: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
};
|
||||
|
||||
// 항목 설정 기본값 (모두 표시)
|
||||
export const DEFAULT_ITEM_VISIBILITY: ItemVisibilitySettings = {
|
||||
notice: {
|
||||
enabled: true,
|
||||
notice: true,
|
||||
event: true,
|
||||
},
|
||||
schedule: {
|
||||
enabled: true,
|
||||
vatReport: true,
|
||||
incomeTaxReport: true,
|
||||
},
|
||||
vendor: {
|
||||
enabled: true,
|
||||
newVendor: true,
|
||||
creditRating: true,
|
||||
},
|
||||
attendance: {
|
||||
enabled: true,
|
||||
annualLeave: true,
|
||||
clockIn: true,
|
||||
late: true,
|
||||
absent: true,
|
||||
},
|
||||
order: {
|
||||
enabled: true,
|
||||
salesOrder: true,
|
||||
purchaseOrder: true,
|
||||
},
|
||||
approval: {
|
||||
enabled: true,
|
||||
approvalRequest: true,
|
||||
draftApproved: true,
|
||||
draftRejected: true,
|
||||
draftCompleted: true,
|
||||
},
|
||||
production: {
|
||||
enabled: true,
|
||||
safetyStock: true,
|
||||
productionComplete: true,
|
||||
},
|
||||
};
|
||||
@@ -107,7 +107,9 @@ export async function getServerApiHeaders(token?: string): Promise<HeadersInit>
|
||||
*/
|
||||
export async function serverFetch(
|
||||
url: string,
|
||||
options?: RequestInit & { skipAuthCheck?: boolean }
|
||||
options?: RequestInit & {
|
||||
skipAuthCheck?: boolean;
|
||||
}
|
||||
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
Reference in New Issue
Block a user