Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-11 11:03:50 +09:00
33 changed files with 1354 additions and 217 deletions

View File

@@ -231,14 +231,14 @@ export default function BoardCodePage() {
};
// 테이블 컬럼
const tableColumns: TableColumn[] = [
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
{ key: 'author', label: '작성자', className: 'w-[120px]' },
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
];
], []);
// 테이블 행 렌더링
const renderTableRow = useCallback(

View File

@@ -244,14 +244,14 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
};
// 테이블 컬럼
const tableColumns: TableColumn[] = [
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
{ key: 'author', label: '작성자', className: 'w-[120px]' },
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
];
], []);
// 테이블 행 렌더링
const renderTableRow = useCallback(

View File

@@ -0,0 +1,120 @@
'use client';
import { useState, useEffect } from 'react';
import { LayoutDashboard } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { RollingText, type RollingItem } from './RollingText';
import { OverviewTab } from './tabs/OverviewTab';
import { FinanceTab } from './tabs/FinanceTab';
import { SalesTab } from './tabs/SalesTab';
import { ExpenseTab } from './tabs/ExpenseTab';
import { ScheduleTab } from './tabs/ScheduleTab';
import {
scheduleStats,
overviewStats,
financeStats,
salesStats,
expenseStats,
scheduleTodayItems,
scheduleIssueItems,
} from './mockData';
interface TabConfig {
value: string;
label: string;
badge?: number;
rollingItems: RollingItem[];
}
const toRolling = (stats: { label: string; value: string; color?: string }[]): RollingItem[] =>
stats.map((s) => ({ label: s.label, value: s.value, color: (s.color ?? 'default') as RollingItem['color'] }));
const TABS: TabConfig[] = [
{
value: 'schedule',
label: '일정/이슈',
badge: scheduleTodayItems.length + scheduleIssueItems.length,
rollingItems: toRolling(scheduleStats),
},
{
value: 'overview',
label: '전체 요약',
rollingItems: toRolling(overviewStats),
},
{
value: 'finance',
label: '재무 관리',
rollingItems: toRolling(financeStats),
},
{
value: 'sales',
label: '영업/매출',
rollingItems: toRolling(salesStats),
},
{
value: 'expense',
label: '경비 관리',
rollingItems: toRolling(expenseStats),
},
];
export function DashboardType2() {
const [activeTab, setActiveTab] = useState('schedule');
const [globalTick, setGlobalTick] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setGlobalTick((prev) => prev + 1);
}, 2500);
return () => clearInterval(timer);
}, []);
return (
<PageLayout>
<div className="p-3 md:p-6">
<PageHeader
title="대시보드"
description="주요 경영 지표를 한눈에 확인합니다."
icon={LayoutDashboard}
/>
<div className="mt-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-transparent border-b rounded-none w-full gap-0 p-0 h-auto justify-start flex-wrap">
{TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="relative flex-none rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-3 py-2.5 lg:px-5 lg:py-3.5 text-sm lg:text-base font-medium text-muted-foreground data-[state=active]:text-primary whitespace-nowrap"
>
<span className="flex items-center gap-1.5">
<span>{tab.label}</span>
{tab.badge != null && tab.badge > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-bold leading-none">
{tab.badge}
</span>
)}
</span>
<span className={`hidden xl:inline-flex items-center gap-1.5 ml-1.5 w-[180px] overflow-hidden ${activeTab === tab.value ? 'invisible' : ''}`}>
<span className="text-muted-foreground/40 flex-shrink-0">|</span>
<span className="flex-1 overflow-hidden"><RollingText items={tab.rollingItems} globalTick={globalTick} /></span>
</span>
</TabsTrigger>
))}
</TabsList>
<div className="mt-6">
<TabsContent value="schedule"><ScheduleTab /></TabsContent>
<TabsContent value="overview"><OverviewTab /></TabsContent>
<TabsContent value="finance"><FinanceTab /></TabsContent>
<TabsContent value="sales"><SalesTab /></TabsContent>
<TabsContent value="expense"><ExpenseTab /></TabsContent>
</div>
</Tabs>
</div>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
export interface RollingItem {
label: string;
value: string;
color?: 'default' | 'green' | 'blue' | 'red' | 'orange';
}
interface RollingTextProps {
items: RollingItem[];
globalTick: number;
}
const valueColorMap: Record<string, string> = {
default: 'text-foreground font-bold',
green: 'text-green-600 font-bold',
blue: 'text-blue-600 font-bold',
red: 'text-red-600 font-bold',
orange: 'text-orange-600 font-bold',
};
export function RollingText({ items, globalTick }: RollingTextProps) {
const [displayIndex, setDisplayIndex] = useState(() =>
items.length > 0 ? globalTick % items.length : 0,
);
const [isAnimating, setIsAnimating] = useState(false);
const isPausedRef = useRef(false);
const displayIndexRef = useRef(displayIndex);
useEffect(() => {
if (items.length <= 1) return;
if (isPausedRef.current) return;
const targetIndex = globalTick % items.length;
if (targetIndex === displayIndexRef.current) return;
setIsAnimating(true);
const timeout = setTimeout(() => {
setDisplayIndex(targetIndex);
displayIndexRef.current = targetIndex;
setIsAnimating(false);
}, 300);
return () => clearTimeout(timeout);
}, [globalTick, items.length]);
const handleMouseEnter = useCallback(() => {
isPausedRef.current = true;
}, []);
const handleMouseLeave = useCallback(() => {
isPausedRef.current = false;
const targetIndex = globalTick % items.length;
setDisplayIndex(targetIndex);
displayIndexRef.current = targetIndex;
}, [globalTick, items.length]);
if (items.length === 0) return null;
const item = items[displayIndex];
return (
<span
className={`inline-flex items-center gap-1.5 text-sm transition-all duration-300 cursor-default ${
isAnimating ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0'
}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span className="text-muted-foreground">{item.label}</span>
<span className={valueColorMap[item.color ?? 'default']}>{item.value}</span>
</span>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import type { StatCard } from './mockData';
const colorMap: Record<string, string> = {
default: 'text-foreground',
green: 'text-green-600',
blue: 'text-blue-600',
red: 'text-red-600',
orange: 'text-orange-600',
};
export function StatCards({ stats }: { stats: StatCard[] }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, i) => (
<div key={i} className="bg-card border rounded-xl p-4">
<p className="text-xs md:text-sm text-muted-foreground mb-1 whitespace-nowrap">{stat.label}</p>
<p className={`text-lg md:text-xl font-bold ${colorMap[stat.color ?? 'default']}`}>
{stat.value}
</p>
{stat.change && (
<p className={`text-xs mt-1 ${stat.changeDirection === 'up' ? 'text-green-500' : 'text-red-500'}`}>
{stat.change}
</p>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,118 @@
/** dashboard_type2 목업 데이터 */
export interface StatCard {
label: string;
value: string;
color?: 'default' | 'green' | 'blue' | 'red' | 'orange';
change?: string;
changeDirection?: 'up' | 'down';
}
export interface TableRow {
[key: string]: string | number;
}
// === 전체 요약 탭 ===
export const overviewStats: StatCard[] = [
{ label: '현금성 자산', value: '305억', color: 'blue', change: '+5.2%', changeDirection: 'up' },
{ label: '당월 매출', value: '12.8억', color: 'green', change: '+8.3%', changeDirection: 'up' },
{ label: '당월 미수금', value: '10.1억', color: 'red', change: '+2.1%', changeDirection: 'up' },
{ label: '당월 지출', value: '8.5억', color: 'orange', change: '-3.2%', changeDirection: 'down' },
];
export const overviewRecentOrders: TableRow[] = [
{ no: 1, : '(주)대한건설', : '전동개폐기 SET', : '45,000,000', : '진행중', : '김영민' },
{ no: 2, : '삼성엔지니어링', : '자동제어 시스템', : '128,000,000', : '완료', : '이수진' },
{ no: 3, : '현대건설', : '환기 시스템', : '67,500,000', : '진행중', : '박준혁' },
{ no: 4, : 'LG전자', : '공조기 제어반', : '32,000,000', : '대기', : '최민지' },
{ no: 5, : 'SK에코플랜트', : '모터 제어반', : '89,000,000', : '진행중', : '정하윤' },
];
// === 재무 관리 탭 ===
export const financeStats: StatCard[] = [
{ label: '현금성 자산 합계', value: '-1,392만', color: 'red', change: '+5.2%', changeDirection: 'up' },
{ label: '외국환(USD) 합계', value: '$0', color: 'default', change: '+2.1%', changeDirection: 'up' },
{ label: '입금 합계', value: '0원', color: 'green', change: '+12.0%', changeDirection: 'up' },
{ label: '출금 합계', value: '0원', color: 'default', change: '-8.0%', changeDirection: 'down' },
];
export const financeExpenseData: TableRow[] = [
{ no: 1, : '매입', : '5,234만', : '+10.5%', : '42%' },
{ no: 2, : '카드', : '985만', : '+3.2%', : '8%' },
{ no: 3, : '발행어음', : '0원', : '-', : '0%' },
{ no: 4, : '인건비', : '3,200만', : '+1.5%', : '26%' },
{ no: 5, : '운영비', : '1,580만', : '-5.3%', : '13%' },
{ no: 6, : '기타', : '1,350만', : '+7.8%', : '11%' },
];
export const financeCardData: TableRow[] = [
{ no: 1, : '법인카드(신한)', : '450만', : '2건', : '1,000만', : '550만' },
{ no: 2, : '법인카드(국민)', : '320만', : '1건', : '800만', : '480만' },
{ no: 3, : '법인카드(하나)', : '215만', : '2건', : '500만', : '285만' },
];
// === 영업/매출 탭 ===
export const salesStats: StatCard[] = [
{ label: '수주 건수', value: '7건', color: 'blue' },
{ label: '누적 미수금', value: '10억 3,186만', color: 'red' },
{ label: '당월 미수금', value: '10억 2,586만', color: 'orange' },
{ label: '채권추심 중', value: '4,782만', color: 'red' },
];
export const salesReceivableData: TableRow[] = [
{ no: 1, : '(주)대한건설', : '120,000,000', : '80,000,000', : '40,000,000', : '45일', : '정상' },
{ no: 2, : '삼성엔지니어링', : '250,000,000', : '150,000,000', : '100,000,000', : '92일', : '주의' },
{ no: 3, : '현대건설', : '67,500,000', : '67,500,000', : '0', : '-', : '완료' },
{ no: 4, : 'SK에코플랜트', : '89,000,000', : '45,000,000', : '44,000,000', : '30일', : '정상' },
{ no: 5, : 'LG전자', : '32,000,000', : '0', : '32,000,000', : '120일', : '위험' },
];
export const salesDebtData: TableRow[] = [
{ no: 1, : '(주)동양전자', : '1,500만', : '내용증명 발송', : '180일', : '중' },
{ no: 2, : '(주)한국테크', : '2,300만', : '법적조치 진행', : '250일', : '하' },
{ no: 3, : '(주)미래산업', : '982만', : '분할상환 협의', : '95일', : '상' },
];
// === 경비 관리 탭 ===
export const expenseStats: StatCard[] = [
{ label: '접대비 사용', value: '1,000만', color: 'blue' },
{ label: '접대비 잔여한도', value: '2,190만', color: 'green' },
{ label: '복리후생비 사용', value: '0원', color: 'default' },
{ label: '복리후생비 잔여한도', value: '960만', color: 'green' },
];
export const expenseEntertainmentData: TableRow[] = [
{ no: 1, : '2026-02-03', : '김대표', : '(주)대한건설', : '350,000', : '식사', : '법인카드' },
{ no: 2, : '2026-02-05', : '이부장', : '삼성엔지니어링', : '520,000', : '골프', : '법인카드' },
{ no: 3, : '2026-02-07', : '김대표', : 'LG전자', : '180,000', : '식사', : '법인카드' },
{ no: 4, : '2026-02-08', : '박이사', : 'SK에코플랜트', : '450,000', : '선물', : '현금' },
];
export const expenseWelfareData: TableRow[] = [
{ no: 1, : '식대', : '200,000', : '180,000', : '20,000', : '비과세 한도 내' },
{ no: 2, : '교통비', : '100,000', : '85,000', : '15,000', : '-' },
{ no: 3, : '경조사비', : '200,000', : '100,000', : '100,000', : '-' },
{ no: 4, : '체육문화비', : '100,000', : '0', : '100,000', : '-' },
];
// === 일정/이슈 탭 ===
export const scheduleStats: StatCard[] = [
{ label: '오늘 일정', value: '4건', color: 'blue' },
{ label: '이번 주 일정', value: '12건', color: 'default' },
{ label: '미처리 이슈', value: '3건', color: 'red' },
{ label: '이번 달 마감', value: '2건', color: 'orange' },
];
export const scheduleTodayItems = [
{ id: 1, time: '09:00', title: '대한건설 현장 미팅', type: 'meeting' as const, person: '김영민' },
{ id: 2, time: '11:00', title: '삼성엔지니어링 견적 검토', type: 'task' as const, person: '이수진' },
{ id: 3, time: '14:00', title: '월간 실적 보고', type: 'report' as const, person: '박준혁' },
{ id: 4, time: '16:00', title: 'SK에코플랜트 납품 확인', type: 'delivery' as const, person: '정하윤' },
];
export const scheduleIssueItems = [
{ id: 1, date: '2026-02-08', title: '전동개폐기 납기 지연 (3일)', priority: 'high' as const, assignee: '김영민' },
{ id: 2, date: '2026-02-09', title: '자재 단가 인상 통보 (동 파이프)', priority: 'medium' as const, assignee: '최민지' },
{ id: 3, date: '2026-02-10', title: '품질 검사 부적합 2건 발생', priority: 'high' as const, assignee: '박준혁' },
{ id: 4, date: '2026-02-10', title: '현대건설 설계 변경 요청', priority: 'medium' as const, assignee: '이수진' },
];

View File

@@ -0,0 +1,83 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { expenseStats, expenseEntertainmentData, expenseWelfareData } from '../mockData';
import { StatCards } from '../StatCards';
export function ExpenseTab() {
return (
<div className="space-y-6">
<StatCards stats={expenseStats} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{expenseEntertainmentData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3">{row['일자']}</td>
<td className="px-4 py-3">{row['사용자']}</td>
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
<td className="px-4 py-3 text-right">{row['금액']}</td>
<td className="px-4 py-3">{row['용도']}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{expenseWelfareData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['항목']}</td>
<td className="px-4 py-3 text-right">{row['월한도']}</td>
<td className="px-4 py-3 text-right">{row['사용액']}</td>
<td className="px-4 py-3 text-right text-green-600 font-medium">{row['잔여']}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{row['비고']}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { financeStats, financeExpenseData, financeCardData } from '../mockData';
import { StatCards } from '../StatCards';
export function FinanceTab() {
return (
<div className="space-y-6">
<StatCards stats={financeStats} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[500px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{financeExpenseData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['항목']}</td>
<td className="px-4 py-3 text-right">{row['금액']}</td>
<td className="px-4 py-3 text-right">
<span className={String(row['전월대비']).startsWith('+') ? 'text-red-500' : String(row['전월대비']).startsWith('-') ? 'text-green-500' : ''}>
{row['전월대비']}
</span>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">{row['비율']}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[500px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{financeCardData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['카드명']}</td>
<td className="px-4 py-3 text-right">{row['사용액']}</td>
<td className="px-4 py-3 text-center text-orange-500 font-medium">{row['미정리']}</td>
<td className="px-4 py-3 text-right">{row['잔여한도']}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { overviewStats, overviewRecentOrders } from '../mockData';
import { StatCards } from '../StatCards';
export function OverviewTab() {
return (
<div className="space-y-6">
<StatCards stats={overviewStats} />
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{overviewRecentOrders.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
<td className="px-4 py-3">{row['품목']}</td>
<td className="px-4 py-3 text-right">{row['금액']}</td>
<td className="px-4 py-3 text-center">
<StatusBadge status={row['상태'] as string} />
</td>
<td className="px-4 py-3">{row['담당자']}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
'진행중': 'bg-blue-100 text-blue-700',
'완료': 'bg-green-100 text-green-700',
'대기': 'bg-gray-100 text-gray-600',
'주의': 'bg-yellow-100 text-yellow-700',
'위험': 'bg-red-100 text-red-700',
};
return (
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>
{status}
</span>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { salesStats, salesReceivableData, salesDebtData } from '../mockData';
import { StatCards } from '../StatCards';
export function SalesTab() {
return (
<div className="space-y-6">
<StatCards stats={salesStats} />
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[700px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{salesReceivableData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
<td className="px-4 py-3 text-right">{row['매출액']}</td>
<td className="px-4 py-3 text-right">{row['입금액']}</td>
<td className="px-4 py-3 text-right font-medium">{row['미수금']}</td>
<td className="px-4 py-3 text-center">{row['경과일']}</td>
<td className="px-4 py-3 text-center">
<StatusBadge status={row['상태'] as string} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[700px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">No</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground"></th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{salesDebtData.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground">{row.no}</td>
<td className="px-4 py-3 font-medium">{row['거래처']}</td>
<td className="px-4 py-3 text-right">{row['채권액']}</td>
<td className="px-4 py-3">{row['추심단계']}</td>
<td className="px-4 py-3 text-center">{row['경과일']}</td>
<td className="px-4 py-3 text-center">
<PossibilityBadge level={row['회수가능성'] as string} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
'정상': 'bg-green-100 text-green-700',
'완료': 'bg-green-100 text-green-700',
'주의': 'bg-yellow-100 text-yellow-700',
'위험': 'bg-red-100 text-red-700',
};
return (
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? 'bg-gray-100 text-gray-600'}`}>
{status}
</span>
);
}
function PossibilityBadge({ level }: { level: string }) {
const styles: Record<string, string> = {
'상': 'bg-green-100 text-green-700',
'중': 'bg-yellow-100 text-yellow-700',
'하': 'bg-red-100 text-red-700',
};
return (
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[level] ?? 'bg-gray-100 text-gray-600'}`}>
{level}
</span>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { scheduleStats, scheduleTodayItems, scheduleIssueItems } from '../mockData';
import { StatCards } from '../StatCards';
import { Clock, AlertTriangle, FileText, Truck, Users } from 'lucide-react';
export function ScheduleTab() {
return (
<div className="space-y-6">
<StatCards stats={scheduleStats} />
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
{scheduleTodayItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<ScheduleIcon type={item.type} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.person}</p>
</div>
<div className="flex-shrink-0">
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">{item.time}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-3">
{scheduleIssueItems.map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
<div className="flex-shrink-0 mt-0.5">
<PriorityIcon priority={item.priority} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{item.date}</span>
<span className="text-xs text-muted-foreground">|</span>
<span className="text-xs text-muted-foreground">{item.assignee}</span>
</div>
</div>
<div className="flex-shrink-0">
<PriorityBadge priority={item.priority} />
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
function ScheduleIcon({ type }: { type: string }) {
const iconClass = "w-4 h-4";
switch (type) {
case 'meeting': return <Users className={`${iconClass} text-blue-500`} />;
case 'task': return <FileText className={`${iconClass} text-green-500`} />;
case 'report': return <Clock className={`${iconClass} text-orange-500`} />;
case 'delivery': return <Truck className={`${iconClass} text-purple-500`} />;
default: return <Clock className={`${iconClass} text-gray-500`} />;
}
}
function PriorityIcon({ priority }: { priority: string }) {
if (priority === 'high') return <AlertTriangle className="w-4 h-4 text-red-500" />;
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
}
function PriorityBadge({ priority }: { priority: string }) {
const styles: Record<string, string> = {
high: 'bg-red-100 text-red-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700',
};
const labels: Record<string, string> = { high: '긴급', medium: '보통', low: '낮음' };
return (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${styles[priority] ?? 'bg-gray-100 text-gray-600'}`}>
{labels[priority] ?? priority}
</span>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { DashboardType2 } from './_components/DashboardType2';
/**
* Dashboard Type 2 - 탭 기반 대시보드
*
* 기존 보고서형 대시보드(dashboard)와 달리 탭으로 세부 항목을 나눈 구성
* - 전체 요약: 핵심 KPI + 최근 수주
* - 재무 관리: 일일일보 + 지출/카드 현황
* - 영업/매출: 미수금 + 채권추심
* - 경비 관리: 접대비 + 복리후생비
* - 일정/이슈: 오늘 일정 + 미처리 이슈
*
* URL: /ko/dashboard_type2
*/
export default function DashboardType2Page() {
return <DashboardType2 />;
}

View File

@@ -4,19 +4,16 @@
* 경로: /[locale]/(protected)/production/dashboard
*/
import { Suspense } from 'react';
import ProductionDashboard from '@/components/production/ProductionDashboard';
'use client';
import dynamic from 'next/dynamic';
import { ListPageSkeleton } from '@/components/ui/skeleton';
export default function ProductionDashboardPage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} showStats={true} statsCount={6} />}>
<ProductionDashboard />
</Suspense>
);
}
const ProductionDashboard = dynamic(
() => import('@/components/production/ProductionDashboard'),
{ loading: () => <ListPageSkeleton showHeader={false} showStats={true} statsCount={6} /> }
);
export const metadata = {
title: '생산 현황판',
description: '공장별 작업 현황을 확인합니다.',
};
export default function ProductionDashboardPage() {
return <ProductionDashboard />;
}

View File

@@ -1,7 +1,13 @@
'use client';
import { MainDashboard } from '@/components/business/MainDashboard';
import dynamic from 'next/dynamic';
import { ListPageSkeleton } from '@/components/ui/skeleton';
const MainDashboard = dynamic(
() => import('@/components/business/MainDashboard').then(mod => ({ default: mod.MainDashboard })),
{ loading: () => <ListPageSkeleton showHeader={false} showStats={true} statsCount={4} /> }
);
export default function ComprehensiveAnalysisPage() {
return <MainDashboard />;
}
}

View File

@@ -15,7 +15,7 @@
* - 페이지 기반 CRUD (등록/수정/상세 → 별도 페이지로 이동)
*/
import { useState, useRef, useEffect, useCallback, useTransition } from "react";
import { useState, useRef, useEffect, useCallback, useTransition, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ClientDetailClientV2 } from '@/components/clients/ClientDetailClientV2';
import { useClientList, Client } from "@/hooks/useClientList";
@@ -435,7 +435,7 @@ export default function CustomerAccountManagementPage() {
};
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
const tableColumns: TableColumn[] = useMemo(() => [
{ key: "rowNumber", label: "번호", className: "px-4" },
{ key: "code", label: "코드", className: "px-4" },
{ key: "clientType", label: "구분", className: "px-4" },
@@ -443,7 +443,7 @@ export default function CustomerAccountManagementPage() {
{ key: "representative", label: "대표자", className: "px-4" },
{ key: "manager", label: "담당자", className: "px-4" },
{ key: "phone", label: "전화번호", className: "px-4" },
];
], []);
// 테이블 행 렌더링
const renderTableRow = (

View File

@@ -11,7 +11,7 @@
* - API 연동 완료 (2025-01-08)
*/
import { useState, useRef, useEffect, useCallback, useTransition } from "react";
import { useState, useRef, useEffect, useCallback, useTransition, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { OrderRegistration, OrderFormData, createOrder } from "@/components/orders";
import {
@@ -473,7 +473,7 @@ function OrderListContent() {
}, []);
// 테이블 컬럼 정의 (16개: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)
const tableColumns: TableColumn[] = [
const tableColumns: TableColumn[] = useMemo(() => [
{ key: "rowNumber", label: "번호", className: "px-2 text-center" },
{ key: "lotNumber", label: "로트번호", className: "px-2" },
{ key: "siteName", label: "현장명", className: "px-2" },
@@ -489,7 +489,7 @@ function OrderListContent() {
{ key: "frameCount", label: "틀수", className: "px-2 text-center" },
{ key: "status", label: "상태", className: "px-2" },
{ key: "remarks", label: "비고", className: "px-2" },
];
], []);
// 테이블 행 렌더링 (16개 컬럼: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)
const renderTableRow = (

View File

@@ -68,44 +68,45 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
const [note, setNote] = useState('');
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
// ===== 거래처 목록 로드 =====
// ===== 초기 데이터 로드 (거래처 + 어음 상세 병렬) =====
useEffect(() => {
async function loadClients() {
const result = await getClients();
if (result.success && result.data) {
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
async function loadInitialData() {
const isEditMode = billId && billId !== 'new';
setIsLoading(!!isEditMode);
const [clientsResult, billResult] = await Promise.all([
getClients(),
isEditMode ? getBill(billId) : Promise.resolve(null),
]);
// 거래처 목록
if (clientsResult.success && clientsResult.data) {
setClients(clientsResult.data.map(c => ({ id: String(c.id), name: c.name })));
}
}
loadClients();
}, []);
// ===== 데이터 로드 =====
useEffect(() => {
async function loadBill() {
if (!billId || billId === 'new') return;
// 어음 상세
if (billResult) {
if (billResult.success && billResult.data) {
const data = billResult.data;
setBillNumber(data.billNumber);
setBillType(data.billType);
setVendorId(data.vendorId);
setAmount(data.amount);
setIssueDate(data.issueDate);
setMaturityDate(data.maturityDate);
setStatus(data.status);
setNote(data.note);
setInstallments(data.installments);
} else {
toast.error(billResult.error || '어음 정보를 불러올 수 없습니다.');
router.push('/ko/accounting/bills');
}
}
setIsLoading(true);
const result = await getBill(billId);
setIsLoading(false);
if (result.success && result.data) {
const data = result.data;
setBillNumber(data.billNumber);
setBillType(data.billType);
setVendorId(data.vendorId);
setAmount(data.amount);
setIssueDate(data.issueDate);
setMaturityDate(data.maturityDate);
setStatus(data.status);
setNote(data.note);
setInstallments(data.installments);
} else {
toast.error(result.error || '어음 정보를 불러올 수 없습니다.');
router.push('/ko/accounting/bills');
}
}
loadBill();
loadInitialData();
}, [billId, router]);
// ===== 저장 핸들러 =====

View File

@@ -55,38 +55,39 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
const [isLoading, setIsLoading] = useState(false);
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
// ===== 거래처 목록 로드 =====
// ===== 초기 데이터 로드 (거래처 + 입금 상세 병렬) =====
useEffect(() => {
const loadVendors = async () => {
const result = await getVendors();
if (result.success) {
setVendors(result.data);
}
};
loadVendors();
}, []);
const loadInitialData = async () => {
const isEditMode = depositId && !isNewMode;
if (isEditMode) setIsLoading(true);
// ===== 데이터 로드 =====
useEffect(() => {
const loadDeposit = async () => {
if (depositId && !isNewMode) {
setIsLoading(true);
const result = await getDepositById(depositId);
if (result.success && result.data) {
setDepositDate(result.data.depositDate);
setAccountName(result.data.accountName);
setDepositorName(result.data.depositorName);
setDepositAmount(result.data.depositAmount);
setNote(result.data.note);
setVendorId(result.data.vendorId);
setDepositType(result.data.depositType);
const [vendorsResult, depositResult] = await Promise.all([
getVendors(),
isEditMode ? getDepositById(depositId) : Promise.resolve(null),
]);
// 거래처 목록
if (vendorsResult.success) {
setVendors(vendorsResult.data);
}
// 입금 상세
if (depositResult) {
if (depositResult.success && depositResult.data) {
setDepositDate(depositResult.data.depositDate);
setAccountName(depositResult.data.accountName);
setDepositorName(depositResult.data.depositorName);
setDepositAmount(depositResult.data.depositAmount);
setNote(depositResult.data.note);
setVendorId(depositResult.data.vendorId);
setDepositType(depositResult.data.depositType);
} else {
toast.error(result.error || '입금 내역을 불러오는데 실패했습니다.');
toast.error(depositResult.error || '입금 내역을 불러오는데 실패했습니다.');
}
setIsLoading(false);
}
};
loadDeposit();
loadInitialData();
}, [depositId, isNewMode]);
// ===== 저장 핸들러 =====

View File

@@ -93,28 +93,29 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
// ===== 다이얼로그 상태 =====
const [documentModalOpen, setDocumentModalOpen] = useState(false);
// ===== 거래처 목록 로드 =====
// ===== 초기 데이터 로드 (거래처 + 매입 상세 병렬) =====
useEffect(() => {
async function loadClients() {
const result = await getClients({ size: 1000, only_active: true });
if (result.success) {
setClients(result.data.map(v => ({
async function loadInitialData() {
const isEditMode = purchaseId && mode !== 'new';
setIsLoading(true);
const [clientsResult, purchaseResult] = await Promise.all([
getClients({ size: 1000, only_active: true }),
isEditMode ? getPurchaseById(purchaseId) : Promise.resolve(null),
]);
// 거래처 목록
if (clientsResult.success) {
setClients(clientsResult.data.map(v => ({
id: v.id,
name: v.vendorName,
})));
}
}
loadClients();
}, []);
// ===== 매입 상세 데이터 로드 =====
useEffect(() => {
async function loadPurchaseDetail() {
if (purchaseId && mode !== 'new') {
setIsLoading(true);
const result = await getPurchaseById(purchaseId);
if (result.success && result.data) {
const data = result.data;
// 매입 상세
if (purchaseResult) {
if (purchaseResult.success && purchaseResult.data) {
const data = purchaseResult.data;
setPurchaseNo(data.purchaseNo);
setPurchaseDate(data.purchaseDate);
setVendorId(data.vendorId);
@@ -126,16 +127,13 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
setWithdrawalAccount(data.withdrawalAccount);
setCreatedAt(data.createdAt);
}
setIsLoading(false);
} else if (isNewMode) {
// 신규: 매입번호는 서버에서 자동 생성
setPurchaseNo('(자동생성)');
setIsLoading(false);
} else {
setIsLoading(false);
}
setIsLoading(false);
}
loadPurchaseDetail();
loadInitialData();
}, [purchaseId, mode, isNewMode]);
// ===== 합계 계산 =====

View File

@@ -99,29 +99,30 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const [showEmailAlert, setShowEmailAlert] = useState(false);
const [emailAlertMessage, setEmailAlertMessage] = useState('');
// ===== 거래처 목록 로드 =====
// ===== 초기 데이터 로드 (거래처 + 매출 상세 병렬) =====
useEffect(() => {
async function loadClients() {
const result = await getClients({ size: 1000, only_active: true });
if (result.success) {
setClients(result.data.map(v => ({
async function loadInitialData() {
const isEditMode = salesId && mode !== 'new';
setIsLoading(true);
const [clientsResult, saleResult] = await Promise.all([
getClients({ size: 1000, only_active: true }),
isEditMode ? getSaleById(salesId) : Promise.resolve(null),
]);
// 거래처 목록
if (clientsResult.success) {
setClients(clientsResult.data.map(v => ({
id: v.id,
name: v.vendorName,
email: v.email,
})));
}
}
loadClients();
}, []);
// ===== 매출 상세 데이터 로드 =====
useEffect(() => {
async function loadSaleDetail() {
if (salesId && mode !== 'new') {
setIsLoading(true);
const result = await getSaleById(salesId);
if (result.success && result.data) {
const data = result.data;
// 매출 상세
if (saleResult) {
if (saleResult.success && saleResult.data) {
const data = saleResult.data;
setSalesNo(data.salesNo);
setSalesDate(data.salesDate);
setVendorId(data.vendorId);
@@ -132,16 +133,13 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
setTransactionStatementIssued(data.transactionStatementIssued);
setNote(data.note || '');
}
setIsLoading(false);
} else if (isNewMode) {
// 신규: 매출번호는 서버에서 자동 생성
setSalesNo('(자동생성)');
setIsLoading(false);
} else {
setIsLoading(false);
}
setIsLoading(false);
}
loadSaleDetail();
loadInitialData();
}, [salesId, mode, isNewMode]);
// ===== 선택된 거래처 정보 =====

View File

@@ -55,38 +55,39 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
const [isLoading, setIsLoading] = useState(false);
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
// ===== 거래처 목록 로드 =====
// ===== 초기 데이터 로드 (거래처 + 출금 상세 병렬) =====
useEffect(() => {
const loadVendors = async () => {
const result = await getVendors();
if (result.success) {
setVendors(result.data);
}
};
loadVendors();
}, []);
const loadInitialData = async () => {
const isEditMode = withdrawalId && !isNewMode;
if (isEditMode) setIsLoading(true);
// ===== 데이터 로드 =====
useEffect(() => {
const loadWithdrawal = async () => {
if (withdrawalId && !isNewMode) {
setIsLoading(true);
const result = await getWithdrawalById(withdrawalId);
if (result.success && result.data) {
setWithdrawalDate(result.data.withdrawalDate);
setAccountName(result.data.accountName);
setRecipientName(result.data.recipientName);
setWithdrawalAmount(result.data.withdrawalAmount);
setNote(result.data.note);
setVendorId(result.data.vendorId);
setWithdrawalType(result.data.withdrawalType);
const [vendorsResult, withdrawalResult] = await Promise.all([
getVendors(),
isEditMode ? getWithdrawalById(withdrawalId) : Promise.resolve(null),
]);
// 거래처 목록
if (vendorsResult.success) {
setVendors(vendorsResult.data);
}
// 출금 상세
if (withdrawalResult) {
if (withdrawalResult.success && withdrawalResult.data) {
setWithdrawalDate(withdrawalResult.data.withdrawalDate);
setAccountName(withdrawalResult.data.accountName);
setRecipientName(withdrawalResult.data.recipientName);
setWithdrawalAmount(withdrawalResult.data.withdrawalAmount);
setNote(withdrawalResult.data.note);
setVendorId(withdrawalResult.data.vendorId);
setWithdrawalType(withdrawalResult.data.withdrawalType);
} else {
toast.error(result.error || '출금 내역을 불러오는데 실패했습니다.');
toast.error(withdrawalResult.error || '출금 내역을 불러오는데 실패했습니다.');
}
setIsLoading(false);
}
};
loadWithdrawal();
loadInitialData();
}, [withdrawalId, isNewMode]);
// ===== 저장 핸들러 =====

View File

@@ -1,9 +1,13 @@
'use client';
import { Suspense } from "react";
import { CEODashboard } from "./CEODashboard";
import dynamic from 'next/dynamic';
import { DetailPageSkeleton } from "@/components/ui/skeleton";
const CEODashboard = dynamic(
() => import('./CEODashboard').then(mod => ({ default: mod.CEODashboard })),
{ loading: () => <DetailPageSkeleton /> }
);
/**
* Dashboard - 대표님 전용 대시보드
*
@@ -24,9 +28,5 @@ import { DetailPageSkeleton } from "@/components/ui/skeleton";
*/
export function Dashboard() {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<CEODashboard />
</Suspense>
);
}
return <CEODashboard />;
}

View File

@@ -1,18 +1,18 @@
'use client';
import { Suspense } from "react";
import { ConstructionMainDashboard } from "./ConstructionMainDashboard";
import dynamic from 'next/dynamic';
import { DetailPageSkeleton } from "@/components/ui/skeleton";
const ConstructionMainDashboard = dynamic(
() => import('./ConstructionMainDashboard').then(mod => ({ default: mod.ConstructionMainDashboard })),
{ loading: () => <DetailPageSkeleton /> }
);
/**
* ConstructionDashboard - 주일기업 전용 대시보드
*
*
* 건설/공사 프로젝트 중심의 메트릭과 현황을 보여줍니다.
*/
export function ConstructionDashboard() {
return (
<Suspense fallback={<DetailPageSkeleton />}>
<ConstructionMainDashboard />
</Suspense>
);
return <ConstructionMainDashboard />;
}

View File

@@ -288,8 +288,8 @@ export default function ItemListClient() {
];
// 양식 다운로드
const handleTemplateDownload = () => {
downloadExcelTemplate({
const handleTemplateDownload = async () => {
await downloadExcelTemplate({
columns: templateColumns,
filename: '품목등록_양식',
sheetName: '품목등록',

View File

@@ -10,7 +10,7 @@
* - 테이블 푸터 (요약 정보)
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import {
Package,
@@ -230,7 +230,7 @@ export function StockStatusList() {
];
// ===== 테이블 컬럼 =====
const tableColumns = [
const tableColumns = useMemo(() => [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
{ key: 'itemType', label: '품목유형', className: 'w-[80px]' },
@@ -241,7 +241,7 @@ export function StockStatusList() {
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
{ key: 'wipQty', label: '재공품', className: 'w-[80px] text-center' },
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
];
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = (

View File

@@ -1,6 +1,6 @@
"use client";
import { ReactNode } from "react";
import { ReactNode, memo, useCallback } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -204,6 +204,62 @@ function getAlignClass<T>(column: Column<T>): string {
}
}
// 메모이즈드 행 컴포넌트 — 행 데이터가 변경되지 않으면 리렌더링 스킵
interface DataTableRowProps<T extends object> {
row: T;
rowIndex: number;
columns: Column<T>[];
onRowClick?: (row: T) => void;
hoverable: boolean;
striped: boolean;
compact: boolean;
rowKey: string;
}
function DataTableRowInner<T extends object>({
row,
rowIndex,
columns,
onRowClick,
hoverable,
striped,
compact,
}: DataTableRowProps<T>) {
const handleClick = useCallback(() => {
onRowClick?.(row);
}, [onRowClick, row]);
return (
<TableRow
onClick={onRowClick ? handleClick : undefined}
className={cn(
onRowClick && "cursor-pointer",
hoverable && "hover:bg-muted/50",
striped && rowIndex % 2 === 1 && "bg-muted/20",
compact && "h-10"
)}
>
{columns.map((column) => {
const value = column.key in row ? row[column.key as keyof T] : null;
return (
<TableCell
key={String(column.key)}
className={cn(
getAlignClass(column),
column.className,
compact && "py-2"
)}
>
{renderCell(column, value, row, rowIndex)}
</TableCell>
);
})}
</TableRow>
);
}
const MemoizedDataTableRow = memo(DataTableRowInner) as typeof DataTableRowInner;
export function DataTable<T extends object>({
columns,
data,
@@ -216,6 +272,11 @@ export function DataTable<T extends object>({
hoverable = true,
compact = false
}: DataTableProps<T>) {
const stableOnRowClick = useCallback(
(row: T) => onRowClick?.(row),
[onRowClick]
);
return (
<Card className="hidden md:block">
<CardContent className="p-0">
@@ -252,32 +313,17 @@ export function DataTable<T extends object>({
</TableRow>
) : (
data.map((row, rowIndex) => (
<TableRow
<MemoizedDataTableRow<T>
key={row[keyField] ? String(row[keyField]) : `row-${rowIndex}`}
onClick={() => onRowClick?.(row)}
className={cn(
onRowClick && "cursor-pointer",
hoverable && "hover:bg-muted/50",
striped && rowIndex % 2 === 1 && "bg-muted/20",
compact && "h-10"
)}
>
{columns.map((column) => {
const value = column.key in row ? row[column.key as keyof T] : null;
return (
<TableCell
key={String(column.key)}
className={cn(
getAlignClass(column),
column.className,
compact && "py-2"
)}
>
{renderCell(column, value, row, rowIndex)}
</TableCell>
);
})}
</TableRow>
row={row}
rowIndex={rowIndex}
columns={columns}
onRowClick={onRowClick ? stableOnRowClick : undefined}
hoverable={hoverable}
striped={striped}
compact={compact}
rowKey={row[keyField] ? String(row[keyField]) : `row-${rowIndex}`}
/>
))
)}
</TableBody>

View File

@@ -8,7 +8,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, FilePlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -147,14 +147,14 @@ export function PriceDistributionList() {
};
// 테이블 컬럼
const tableColumns: TableColumn[] = [
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
{ key: 'distributionNo', label: '단가배포번호', className: 'min-w-[120px]' },
{ key: 'distributionName', label: '단가배포명', className: 'min-w-[150px]' },
{ key: 'status', label: '상태', className: 'min-w-[100px]' },
{ key: 'author', label: '작성자', className: 'min-w-[100px]' },
{ key: 'createdAt', label: '등록일', className: 'min-w-[120px]' },
];
], []);
// 테이블 행 렌더링
const renderTableRow = (

View File

@@ -196,7 +196,7 @@ export function PricingListClient({
];
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
{ key: 'itemType', label: '품목유형', className: 'min-w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
@@ -209,7 +209,7 @@ export function PricingListClient({
{ key: 'marginRate', label: '마진율', className: 'min-w-[80px] text-right', hideOnMobile: true },
{ key: 'effectiveDate', label: '적용일', className: 'min-w-[100px]', hideOnMobile: true },
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
];
], []);
// 테이블 행 렌더링
const renderTableRow = (

View File

@@ -35,7 +35,7 @@ import { DeleteConfirmDialog } from "../ui/confirm-dialog";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
import * as XLSX from "xlsx";
// xlsx는 동적 로드 (번들 크기 최적화)
// =============================================================================
// 상수
@@ -181,7 +181,8 @@ export function LocationListPanel({
}, [formData, finishedGoods, onAddLocation]);
// 엑셀 양식 다운로드
const handleDownloadTemplate = useCallback(() => {
const handleDownloadTemplate = useCallback(async () => {
const XLSX = await import("xlsx");
const templateData = [
{
: "1층",
@@ -219,10 +220,11 @@ export function LocationListPanel({
// 엑셀 업로드
const handleFileUpload = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const XLSX = await import("xlsx");
const reader = new FileReader();
reader.onload = (e) => {
try {

View File

@@ -628,7 +628,7 @@ export function UniversalListPage<T>({
return;
}
downloadExcel({
await downloadExcel({
data: dataToDownload as Record<string, unknown>[],
columns: columns as ExcelColumn<Record<string, unknown>>[],
filename,
@@ -645,7 +645,7 @@ export function UniversalListPage<T>({
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, debouncedSearchValue]);
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = useCallback(() => {
const handleSelectedExcelDownload = useCallback(async () => {
if (!config.excelDownload) return;
const { columns, filename = 'export', sheetName = 'Sheet1' } = config.excelDownload;
@@ -659,7 +659,7 @@ export function UniversalListPage<T>({
// 현재 데이터에서 선택된 항목 필터링
const selectedData = rawData.filter((item) => selectedIds.includes(getItemId(item)));
downloadSelectedExcel({
await downloadSelectedExcel({
data: selectedData as Record<string, unknown>[],
columns: columns as ExcelColumn<Record<string, unknown>>[],
selectedIds,

View File

@@ -22,7 +22,11 @@
* ```
*/
import * as XLSX from 'xlsx';
// xlsx는 ~400KB로 무거워서, 실제 사용 시점에 동적 로드
async function loadXLSX() {
const XLSX = await import('xlsx');
return XLSX;
}
/**
* 엑셀 컬럼 정의
@@ -84,19 +88,21 @@ function generateFilename(baseName: string, appendDate: boolean): string {
/**
* 데이터를 엑셀 파일로 다운로드
*/
export function downloadExcel<T extends Record<string, unknown>>({
export async function downloadExcel<T extends Record<string, unknown>>({
data,
columns,
filename = 'export',
sheetName = 'Sheet1',
appendDate = true,
}: ExcelDownloadOptions<T>): void {
}: ExcelDownloadOptions<T>): Promise<void> {
if (!data || data.length === 0) {
console.warn('[Excel] 다운로드할 데이터가 없습니다.');
return;
}
try {
const XLSX = await loadXLSX();
// 1. 헤더 행 생성
const headers = columns.map((col) => col.header);
@@ -162,7 +168,7 @@ export function downloadExcel<T extends Record<string, unknown>>({
/**
* 선택된 항목만 엑셀로 다운로드
*/
export function downloadSelectedExcel<T extends Record<string, unknown>>({
export async function downloadSelectedExcel<T extends Record<string, unknown>>({
data,
selectedIds,
idField = 'id',
@@ -170,7 +176,7 @@ export function downloadSelectedExcel<T extends Record<string, unknown>>({
}: ExcelDownloadOptions<T> & {
selectedIds: string[];
idField?: keyof T | string;
}): void {
}): Promise<void> {
const selectedData = data.filter((item) => {
const id = getNestedValue(item as Record<string, unknown>, idField as string);
return selectedIds.includes(String(id));
@@ -181,7 +187,7 @@ export function downloadSelectedExcel<T extends Record<string, unknown>>({
return;
}
downloadExcel({
await downloadExcel({
...options,
data: selectedData,
});
@@ -244,14 +250,15 @@ export interface TemplateDownloadOptions {
* });
* ```
*/
export function downloadExcelTemplate({
export async function downloadExcelTemplate({
columns,
filename = '업로드_양식',
sheetName = 'Sheet1',
includeSampleRow = true,
includeGuideRow = true,
}: TemplateDownloadOptions): void {
}: TemplateDownloadOptions): Promise<void> {
try {
const XLSX = await loadXLSX();
const wsData: (string | number | boolean)[][] = [];
// 1. 헤더 행 (필수 표시 포함)
@@ -384,6 +391,7 @@ export async function parseExcelFile<T = Record<string, unknown>>(
}
): Promise<ExcelParseResult<T>> {
const { columns, skipRows = 1, sheetIndex = 0 } = options;
const XLSX = await loadXLSX();
return new Promise((resolve) => {
const reader = new FileReader();