[feat]: Shadcn UI 모달 Select 레이아웃 시프트 방지 및 코드 정리
주요 변경사항:
- 테마/언어 선택을 모달 스타일로 변경 (native={false})
- LoginPage, SignupPage, DashboardLayout 적용
- CSS 2줄로 레이아웃 시프트 완전 제거
- body { overflow: visible !important }
- body[data-scroll-locked] { margin-right: 0 !important }
- 미사용 business 컴포넌트 대량 삭제 (코드 정리)
- CEODashboard → MainDashboard 이름 변경
- 구현 문서 작성: [IMPL-2025-11-12] modal-select-layout-shift-fix.md
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -99,3 +99,6 @@ build/
|
||||
claudedocs/
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# ---> Unused components (archived)
|
||||
src/components/_unused/
|
||||
|
||||
@@ -14,7 +14,7 @@ const eslintConfig = [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"next-env.d.ts",
|
||||
"src/components/business/**", // Demo/example components
|
||||
"src/components/_unused/**", // Archived unused components
|
||||
"src/hooks/useCurrentTime.ts", // Demo hook
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { EmptyPage } from '@/components/common/EmptyPage';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
@@ -10,16 +14,89 @@ interface PageProps {
|
||||
/**
|
||||
* Catch-all 라우트: 정의되지 않은 모든 경로를 처리
|
||||
*
|
||||
* 예시:
|
||||
* - /base/product/lists → EmptyPage 표시
|
||||
* - /system/user/lists → EmptyPage 표시
|
||||
* - /custom/path → EmptyPage 표시
|
||||
* 로직:
|
||||
* 1. localStorage의 user.menu에서 유효한 경로인지 확인
|
||||
* 2. 메뉴에 있는 경로 (구현 안됨) → EmptyPage 표시
|
||||
* 3. 메뉴에 없는 완전 엉뚱한 경로 → not-found 페이지 표시
|
||||
*
|
||||
* 실제 페이지를 추가하려면 해당 경로에 page.tsx 파일을 생성하세요.
|
||||
* 예시:
|
||||
* - /base/product/lists (메뉴에 있음) → EmptyPage
|
||||
* - /system/user/lists (메뉴에 있음) → EmptyPage
|
||||
* - /completely/random/path (메뉴에 없음) → 404
|
||||
*/
|
||||
export default async function CatchAllPage({ params }: PageProps) {
|
||||
const { slug: _slug } = await params;
|
||||
interface MenuItem {
|
||||
path?: string;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
export default function CatchAllPage({ params }: PageProps) {
|
||||
const [isValidPath, setIsValidPath] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPath = async () => {
|
||||
const { slug } = await params;
|
||||
|
||||
// localStorage에서 메뉴 데이터 가져오기
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (!userStr) {
|
||||
console.log('❌ localStorage에 user 정보 없음');
|
||||
setIsValidPath(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = JSON.parse(userStr);
|
||||
const menus = userData.menu || [];
|
||||
|
||||
// slug를 경로로 변환 (예: ['base', 'product', 'lists'] → '/base/product/lists')
|
||||
const requestedPath = `/${slug.join('/')}`;
|
||||
|
||||
console.log('🔍 요청된 경로:', requestedPath);
|
||||
console.log('📋 메뉴 데이터:', menus);
|
||||
|
||||
// 메뉴 구조를 재귀적으로 탐색하여 경로 확인
|
||||
const isPathInMenu = (menuItems: MenuItem[], path: string): boolean => {
|
||||
for (const item of menuItems) {
|
||||
console.log(' - 비교 중:', item.path, 'vs', path);
|
||||
|
||||
// path가 요청된 경로와 일치하는지 확인
|
||||
if (item.path === path) {
|
||||
console.log(' ✅ 일치!');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 하위 메뉴 확인
|
||||
if (item.children && item.children.length > 0) {
|
||||
if (isPathInMenu(item.children, path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const pathExists = isPathInMenu(menus, requestedPath);
|
||||
console.log('📌 경로 존재 여부:', pathExists);
|
||||
setIsValidPath(pathExists);
|
||||
};
|
||||
|
||||
void checkPath();
|
||||
}, [params]);
|
||||
|
||||
// 로딩 중
|
||||
if (isValidPath === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메뉴에 없는 경로 → 404
|
||||
if (!isValidPath) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 메뉴에 있지만 구현되지 않은 페이지 → EmptyPage
|
||||
return (
|
||||
<EmptyPage
|
||||
iconName="FileSearch"
|
||||
|
||||
@@ -2,12 +2,28 @@ import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Auth Check Route Handler
|
||||
* 🔵 Next.js 내부 API - 인증 상태 확인 (PHP 백엔드 X)
|
||||
*
|
||||
* Purpose:
|
||||
* - Check if user is authenticated (HttpOnly cookie validation)
|
||||
* - Prevent browser back button cache issues
|
||||
* - Real-time authentication validation
|
||||
* ⚡ 설계 목적:
|
||||
* - 성능 최적화: 매번 PHP 백엔드 호출 대신 로컬 쿠키만 확인
|
||||
* - 백엔드 부하 감소: 간단한 인증 확인은 Next.js에서 처리
|
||||
* - 사용자 경험: 즉시 응답으로 빠른 페이지 전환
|
||||
*
|
||||
* 📍 사용 위치:
|
||||
* - LoginPage.tsx: 이미 로그인된 사용자를 대시보드로 리다이렉트
|
||||
* - SignupPage.tsx: 이미 로그인된 사용자를 대시보드로 리다이렉트
|
||||
* - 뒤로가기 시 캐시 문제 방지
|
||||
*
|
||||
* 🔄 동작 방식:
|
||||
* 1. HttpOnly 쿠키에서 access_token, refresh_token 확인
|
||||
* 2. access_token 있음 → { authenticated: true } 즉시 응답
|
||||
* 3. refresh_token만 있음 → PHP /api/v1/refresh 호출하여 토큰 갱신
|
||||
* 4. 둘 다 없음 → { authenticated: false } 응답
|
||||
*
|
||||
* ⚠️ 주의:
|
||||
* - 이 API는 PHP 백엔드에 존재하지 않습니다
|
||||
* - Next.js 프론트엔드 자체 유틸리티 API입니다
|
||||
* - 실제 인증 로직은 여전히 PHP 백엔드가 담당합니다
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -18,7 +34,7 @@ export async function GET(request: NextRequest) {
|
||||
// No tokens at all - not authenticated
|
||||
if (!accessToken && !refreshToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated', authenticated: false },
|
||||
{ error: 'Not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
@@ -34,6 +50,8 @@ export async function GET(request: NextRequest) {
|
||||
// Only has refresh token - try to refresh
|
||||
if (refreshToken && !accessToken) {
|
||||
console.log('🔄 Access token missing, attempting refresh...');
|
||||
console.log('🔍 Refresh token exists:', refreshToken.substring(0, 20) + '...');
|
||||
console.log('🔍 Backend URL:', process.env.NEXT_PUBLIC_API_URL);
|
||||
|
||||
// Attempt token refresh
|
||||
try {
|
||||
@@ -42,11 +60,15 @@ export async function GET(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${refreshToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('🔍 Refresh API response status:', refreshResponse.status);
|
||||
|
||||
if (refreshResponse.ok) {
|
||||
const data = await refreshResponse.json();
|
||||
|
||||
@@ -80,21 +102,25 @@ export async function GET(request: NextRequest) {
|
||||
response.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
|
||||
return response;
|
||||
} else {
|
||||
const errorData = await refreshResponse.text();
|
||||
console.error('❌ Refresh API failed:', refreshResponse.status, errorData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed in auth check:', error);
|
||||
console.error('❌ Token refresh failed in auth check:', error);
|
||||
}
|
||||
|
||||
// Refresh failed - not authenticated
|
||||
console.log('⚠️ Returning 401 due to refresh failure');
|
||||
return NextResponse.json(
|
||||
{ error: 'Token refresh failed', authenticated: false },
|
||||
{ error: 'Token refresh failed' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback - not authenticated
|
||||
return NextResponse.json(
|
||||
{ error: 'Not authenticated', authenticated: false },
|
||||
{ error: 'Not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* 🔵 Next.js 내부 API - 로그인 프록시 (PHP 백엔드로 전달)
|
||||
*
|
||||
* ⚡ 설계 목적:
|
||||
* - 보안: HttpOnly 쿠키로 토큰 저장 (JavaScript 접근 불가)
|
||||
* - 프록시 패턴: PHP 백엔드 API 호출 후 토큰을 안전하게 쿠키로 설정
|
||||
* - 클라이언트 보호: 토큰을 절대 클라이언트 JavaScript에 노출하지 않음
|
||||
*
|
||||
* 🔄 동작 흐름:
|
||||
* 1. 클라이언트 → Next.js /api/auth/login (user_id, user_pwd)
|
||||
* 2. Next.js → PHP /api/v1/login (인증 요청)
|
||||
* 3. PHP → Next.js (access_token, refresh_token, 사용자 정보)
|
||||
* 4. Next.js: 토큰을 HttpOnly 쿠키로 설정
|
||||
* 5. Next.js → 클라이언트 (토큰 제외한 사용자 정보만 전달)
|
||||
*
|
||||
* 🔐 보안 특징:
|
||||
* - 토큰은 클라이언트에 절대 노출되지 않음
|
||||
* - HttpOnly: XSS 공격 방지
|
||||
* - Secure: HTTPS만 전송
|
||||
* - SameSite=Strict: CSRF 공격 방지
|
||||
*
|
||||
* ⚠️ 주의:
|
||||
* - 이 API는 PHP /api/v1/login의 프록시입니다
|
||||
* - 실제 인증 로직은 PHP 백엔드에서 처리됩니다
|
||||
*/
|
||||
|
||||
/**
|
||||
* 백엔드 API 로그인 응답 타입
|
||||
*/
|
||||
@@ -23,7 +49,7 @@ interface BackendLoginResponse {
|
||||
company_name: string;
|
||||
business_num: string;
|
||||
tenant_st_code: string;
|
||||
other_tenants: any[];
|
||||
other_tenants: unknown[];
|
||||
};
|
||||
menus: Array<{
|
||||
id: number;
|
||||
|
||||
@@ -2,12 +2,28 @@ import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Logout Proxy Route Handler
|
||||
* 🔵 Next.js 내부 API - 로그아웃 프록시 (PHP 백엔드로 전달)
|
||||
*
|
||||
* Purpose:
|
||||
* - Call PHP backend logout API
|
||||
* - Clear HttpOnly cookie
|
||||
* - Ensure complete session cleanup
|
||||
* ⚡ 설계 목적:
|
||||
* - 완전한 로그아웃: PHP 백엔드 토큰 무효화 + 쿠키 삭제
|
||||
* - 보안: HttpOnly 쿠키에서 토큰을 읽어 백엔드로 전달
|
||||
* - 세션 정리: 클라이언트와 서버 양쪽 모두 세션 종료
|
||||
*
|
||||
* 🔄 동작 흐름:
|
||||
* 1. 클라이언트 → Next.js /api/auth/logout
|
||||
* 2. Next.js: HttpOnly 쿠키에서 access_token 읽기
|
||||
* 3. Next.js → PHP /api/v1/logout (토큰 무효화 요청)
|
||||
* 4. Next.js: access_token, refresh_token 쿠키 삭제
|
||||
* 5. Next.js → 클라이언트 (로그아웃 성공 응답)
|
||||
*
|
||||
* 🔐 보안 특징:
|
||||
* - 백엔드에서 토큰 블랙리스트 처리 (재사용 방지)
|
||||
* - 쿠키 완전 삭제 (Max-Age=0)
|
||||
* - 로그아웃 실패해도 쿠키는 삭제 (클라이언트 보호)
|
||||
*
|
||||
* ⚠️ 주의:
|
||||
* - 이 API는 PHP /api/v1/logout의 프록시입니다
|
||||
* - 백엔드 호출 실패해도 쿠키는 삭제됩니다 (안전 우선)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -2,12 +2,34 @@ import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Token Refresh Route Handler
|
||||
* 🔵 Next.js 내부 API - 토큰 갱신 프록시 (PHP 백엔드로 전달)
|
||||
*
|
||||
* Purpose:
|
||||
* - Refresh expired access_token using refresh_token
|
||||
* - Update HttpOnly cookies with new tokens
|
||||
* - Maintain user session without re-login
|
||||
* ⚡ 설계 목적:
|
||||
* - 자동 토큰 갱신: access_token 만료 시 재로그인 없이 세션 유지
|
||||
* - 보안: refresh_token을 HttpOnly 쿠키에서 읽어 백엔드로 전달
|
||||
* - 사용자 경험: 끊김없는 사용 (2시간마다 자동 갱신)
|
||||
*
|
||||
* 🔄 동작 흐름:
|
||||
* 1. 클라이언트 → Next.js /api/auth/refresh
|
||||
* 2. Next.js: HttpOnly 쿠키에서 refresh_token 읽기
|
||||
* 3. Next.js → PHP /api/v1/refresh (새 토큰 요청)
|
||||
* 4. PHP → Next.js (새 access_token, refresh_token)
|
||||
* 5. Next.js: 새 토큰을 HttpOnly 쿠키로 업데이트
|
||||
* 6. Next.js → 클라이언트 (갱신 성공 응답)
|
||||
*
|
||||
* 📍 호출 시점:
|
||||
* - /api/auth/check에서 access_token 없을 때 자동 호출
|
||||
* - API 호출 시 401 응답 받을 때 (withTokenRefresh 헬퍼)
|
||||
* - 미들웨어에서 토큰 만료 감지 시
|
||||
*
|
||||
* 🔐 보안 특징:
|
||||
* - Token Rotation: 갱신 시 새로운 refresh_token도 발급
|
||||
* - HttpOnly: 토큰이 JavaScript에 노출되지 않음
|
||||
* - 자동 만료: access_token 2시간, refresh_token 7일
|
||||
*
|
||||
* ⚠️ 주의:
|
||||
* - 이 API는 PHP /api/v1/refresh의 프록시입니다
|
||||
* - refresh_token 만료 시 재로그인 필요 (401 응답)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -27,9 +49,11 @@ export async function POST(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${refreshToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -2,12 +2,34 @@ import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Signup Proxy Route Handler
|
||||
* 🔵 Next.js 내부 API - 회원가입 프록시 (PHP 백엔드로 전달)
|
||||
*
|
||||
* Purpose:
|
||||
* - Proxy signup requests to PHP backend
|
||||
* - Handle registration errors gracefully
|
||||
* - Return user-friendly error messages
|
||||
* ⚡ 설계 목적:
|
||||
* - 입력 검증: 클라이언트에서 받은 데이터 유효성 확인
|
||||
* - 에러 처리: 백엔드 에러를 사용자 친화적인 메시지로 변환
|
||||
* - 보안: 백엔드 상세 에러 메시지 노출 방지
|
||||
*
|
||||
* 🔄 동작 흐름:
|
||||
* 1. 클라이언트 → Next.js /api/auth/signup (회원가입 정보)
|
||||
* 2. Next.js: 필수 필드 유효성 검증
|
||||
* 3. Next.js → PHP /api/v1/register (회원가입 요청)
|
||||
* 4. PHP → Next.js (성공/실패 응답)
|
||||
* 5. Next.js → 클라이언트 (사용자 친화적 메시지)
|
||||
*
|
||||
* 📋 필수 필드:
|
||||
* - user_id, name, email, phone
|
||||
* - password, password_confirmation
|
||||
* - company_name, business_num, company_scale, industry
|
||||
* - position (선택)
|
||||
*
|
||||
* 🔐 보안 특징:
|
||||
* - 백엔드 상세 에러 숨김 (정보 유출 방지)
|
||||
* - 일반화된 에러 메시지 제공
|
||||
* - 입력 데이터 사전 검증
|
||||
*
|
||||
* ⚠️ 주의:
|
||||
* - 이 API는 PHP /api/v1/register의 프록시입니다
|
||||
* - 회원가입 시 토큰은 발급하지 않음 (로그인 페이지로 리다이렉트)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -204,6 +204,11 @@
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
/* 🔧 Always show scrollbar to prevent layout shift */
|
||||
/*overflow-y: scroll;*/
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
@@ -213,6 +218,21 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
/* 🔧 Body has no overflow - html handles all scrolling */
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* 🔧 Override Radix's scroll-lock completely to prevent any layout shift */
|
||||
body[data-scroll-locked] {
|
||||
/*overflow: visible !important;*/
|
||||
/*position: static !important;*/
|
||||
/*padding-right: 0 !important;*/
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* 🔧 Prevent scroll on modal backdrop instead of body */
|
||||
[data-radix-portal] {
|
||||
/*position: fixed;*/
|
||||
}
|
||||
|
||||
/* Clean glass utilities */
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { locales, localeNames, localeFlags, type Locale } from '@/i18n/config';
|
||||
|
||||
/**
|
||||
* Language Switcher Component
|
||||
*
|
||||
* Allows users to switch between available locales
|
||||
* Usage: Place in header or navigation bar
|
||||
*/
|
||||
export default function LanguageSwitcher() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const handleLocaleChange = (newLocale: Locale) => {
|
||||
// Get the pathname without the current locale
|
||||
const pathnameWithoutLocale = pathname.replace(`/${locale}`, '');
|
||||
|
||||
// Navigate to the new locale
|
||||
router.push(`/${newLocale}${pathnameWithoutLocale}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{locales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => handleLocaleChange(loc)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
locale === loc
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
aria-label={`Switch to ${localeNames[loc]}`}
|
||||
>
|
||||
<span className="mr-1">{localeFlags[loc]}</span>
|
||||
{localeNames[loc]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
/**
|
||||
* Navigation Menu Component
|
||||
*
|
||||
* Demonstrates translation in navigation elements
|
||||
* Shows how to use translations with dynamic content
|
||||
*/
|
||||
export default function NavigationMenu() {
|
||||
const t = useTranslations('navigation');
|
||||
const locale = useLocale();
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'dashboard' as const, href: '/dashboard' },
|
||||
{ key: 'inventory' as const, href: '/inventory' },
|
||||
{ key: 'finance' as const, href: '/finance' },
|
||||
{ key: 'hr' as const, href: '/hr' },
|
||||
{ key: 'crm' as const, href: '/crm' },
|
||||
{ key: 'reports' as const, href: '/reports' },
|
||||
{ key: 'settings' as const, href: '/settings' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-100 dark:bg-gray-900 p-4 rounded-lg">
|
||||
<ul className="flex flex-wrap gap-4">
|
||||
{menuItems.map((item) => (
|
||||
<li key={item.key}>
|
||||
<Link
|
||||
href={`/${locale}${item.href}`}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* Welcome Message Component
|
||||
*
|
||||
* Demonstrates basic translation usage
|
||||
* Shows how to use useTranslations hook in client components
|
||||
*/
|
||||
export default function WelcomeMessage() {
|
||||
const t = useTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-2">{t('welcome')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t('appName')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,14 +33,17 @@ export function LoginPage() {
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// 🔵 Next.js 내부 API - 쿠키에서 토큰 확인 (PHP 호출 X, 성능 최적화)
|
||||
const response = await fetch('/api/auth/check');
|
||||
|
||||
if (response.ok) {
|
||||
// 이미 로그인됨 → 대시보드로 리다이렉트 (replace로 히스토리에서 제거)
|
||||
router.replace('/dashboard');
|
||||
return;
|
||||
}
|
||||
// 인증 안됨 (401) → 현재 페이지 유지
|
||||
} catch {
|
||||
// 인증 안됨 → 현재 페이지 유지
|
||||
// API 호출 실패 → 현재 페이지 유지
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
@@ -59,8 +62,7 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
// ✅ HttpOnly Cookie 방식: Next.js API Route로 프록시
|
||||
// 토큰은 JavaScript에서 접근 불가능한 HttpOnly 쿠키로 저장됨
|
||||
// 🔵 Next.js 프록시 → PHP /api/v1/login (토큰을 HttpOnly 쿠키로 저장)
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -156,8 +158,8 @@ export function LoginPage() {
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSelect />
|
||||
<LanguageSelect />
|
||||
<ThemeSelect native={false} />
|
||||
<LanguageSelect native={false} />
|
||||
<Button variant="ghost" onClick={() => router.push("/signup")} className="rounded-xl">
|
||||
{t('signUp')}
|
||||
</Button>
|
||||
|
||||
@@ -127,14 +127,17 @@ export function SignupPage() {
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// 🔵 Next.js 내부 API - 쿠키에서 토큰 확인 (PHP 호출 X, 성능 최적화)
|
||||
const response = await fetch('/api/auth/check');
|
||||
|
||||
if (response.ok) {
|
||||
// 이미 로그인됨 → 대시보드로 리다이렉트 (replace로 히스토리에서 제거)
|
||||
router.replace('/dashboard');
|
||||
return;
|
||||
}
|
||||
// 인증 안됨 (401) → 현재 페이지 유지
|
||||
} catch {
|
||||
// 인증 안됨 → 현재 페이지 유지
|
||||
// API 호출 실패 → 현재 페이지 유지
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
@@ -163,6 +166,7 @@ export function SignupPage() {
|
||||
industry: formData.industry,
|
||||
};
|
||||
|
||||
// 🔵 Next.js 프록시 → PHP /api/v1/register (회원가입 처리)
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -282,8 +286,8 @@ export function SignupPage() {
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSelect />
|
||||
<LanguageSelect />
|
||||
<ThemeSelect native={false} />
|
||||
<LanguageSelect native={false} />
|
||||
<Button variant="ghost" onClick={() => router.push("/login")} className="rounded-xl">
|
||||
{t("login")}
|
||||
</Button>
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
PieChart,
|
||||
Calculator,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Building2,
|
||||
Calendar
|
||||
} from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart as RechartsPieChart, Cell } from "recharts";
|
||||
|
||||
export function AccountingManagement() {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState("month");
|
||||
|
||||
// 매출/매입 데이터
|
||||
const salesPurchaseData = [
|
||||
{ month: "1월", sales: 450, purchase: 280, profit: 170 },
|
||||
{ month: "2월", sales: 520, purchase: 310, profit: 210 },
|
||||
{ month: "3월", sales: 480, purchase: 295, profit: 185 },
|
||||
{ month: "4월", sales: 610, purchase: 350, profit: 260 },
|
||||
{ month: "5월", sales: 580, purchase: 340, profit: 240 },
|
||||
{ month: "6월", sales: 650, purchase: 380, profit: 270 }
|
||||
];
|
||||
|
||||
// 거래처별 미수금
|
||||
const receivables = [
|
||||
{ company: "삼성전자", amount: 45000000, days: 45, status: "위험" },
|
||||
{ company: "LG전자", amount: 32000000, days: 28, status: "주의" },
|
||||
{ company: "현대자동차", amount: 28000000, days: 15, status: "정상" },
|
||||
{ company: "SK하이닉스", amount: 25000000, days: 52, status: "위험" },
|
||||
{ company: "네이버", amount: 18000000, days: 22, status: "정상" }
|
||||
];
|
||||
|
||||
// 건별 원가 분석
|
||||
const costAnalysis = [
|
||||
{
|
||||
orderNo: "ORD-2024-001",
|
||||
product: "방화셔터 3000×3000",
|
||||
salesAmount: 15000000,
|
||||
materialCost: 6500000,
|
||||
laborCost: 3500000,
|
||||
overheadCost: 2000000,
|
||||
totalCost: 12000000,
|
||||
profit: 3000000,
|
||||
profitRate: 20.0
|
||||
},
|
||||
{
|
||||
orderNo: "ORD-2024-002",
|
||||
product: "일반셔터 2500×2500",
|
||||
salesAmount: 8500000,
|
||||
materialCost: 3200000,
|
||||
laborCost: 2100000,
|
||||
overheadCost: 1200000,
|
||||
totalCost: 6500000,
|
||||
profit: 2000000,
|
||||
profitRate: 23.5
|
||||
},
|
||||
{
|
||||
orderNo: "ORD-2024-003",
|
||||
product: "특수셔터 4000×3500",
|
||||
salesAmount: 22000000,
|
||||
materialCost: 9500000,
|
||||
laborCost: 5200000,
|
||||
overheadCost: 2800000,
|
||||
totalCost: 17500000,
|
||||
profit: 4500000,
|
||||
profitRate: 20.5
|
||||
}
|
||||
];
|
||||
|
||||
// 원가 구성 비율
|
||||
const costComposition = [
|
||||
{ name: "자재비", value: 54, color: "#3B82F6" },
|
||||
{ name: "인건비", value: 29, color: "#10B981" },
|
||||
{ name: "경비", value: 17, color: "#F59E0B" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">회계 관리</h1>
|
||||
<p className="text-muted-foreground">매출/매입, 미수금, 원가 분석 및 손익 현황</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
월마감
|
||||
</Button>
|
||||
<Button className="bg-primary">
|
||||
<Calculator className="h-4 w-4 mr-2" />
|
||||
재무제표
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 주요 지표 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center justify-between">
|
||||
<span>당월 매출</span>
|
||||
<DollarSign className="h-4 w-4 text-green-600" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground mb-2">650M원</div>
|
||||
<div className="flex items-center space-x-1 text-sm text-green-600">
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
<span>전월 대비 +12.1%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center justify-between">
|
||||
<span>당월 매입</span>
|
||||
<CreditCard className="h-4 w-4 text-orange-600" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground mb-2">380M원</div>
|
||||
<div className="flex items-center space-x-1 text-sm text-orange-600">
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
<span>전월 대비 +11.8%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center justify-between">
|
||||
<span>당월 순이익</span>
|
||||
<TrendingUp className="h-4 w-4 text-blue-600" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground mb-2">270M원</div>
|
||||
<div className="flex items-center space-x-1 text-sm text-blue-600">
|
||||
<span>이익률: 41.5%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center justify-between">
|
||||
<span>총 미수금</span>
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground mb-2">148M원</div>
|
||||
<div className="flex items-center space-x-1 text-sm text-red-600">
|
||||
<span>30일 초과: 70M원</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 탭 메뉴 */}
|
||||
<Tabs defaultValue="sales" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="sales">매출/매입</TabsTrigger>
|
||||
<TabsTrigger value="receivables">미수금 관리</TabsTrigger>
|
||||
<TabsTrigger value="cost">원가 분석</TabsTrigger>
|
||||
<TabsTrigger value="profit">손익 현황</TabsTrigger>
|
||||
<TabsTrigger value="transactions">입출금</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 매출/매입 관리 */}
|
||||
<TabsContent value="sales" className="space-y-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<TrendingUp className="h-6 w-6 text-primary" />
|
||||
<span>매출/매입 추이</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={salesPurchaseData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="sales" fill="#10B981" name="매출" />
|
||||
<Bar dataKey="purchase" fill="#F59E0B" name="매입" />
|
||||
<Bar dataKey="profit" fill="#3B82F6" name="이익" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 미수금 관리 */}
|
||||
<TabsContent value="receivables" className="space-y-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<CreditCard className="h-6 w-6 text-red-600" />
|
||||
<span>거래처별 미수금 현황</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>거래처</TableHead>
|
||||
<TableHead className="text-right">미수금액</TableHead>
|
||||
<TableHead className="text-center">경과일수</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
<TableHead className="text-center">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{receivables.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-medium">{item.company}</TableCell>
|
||||
<TableCell className="text-right font-bold">
|
||||
{item.amount.toLocaleString()}원
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.days}일</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
className={
|
||||
item.status === "위험"
|
||||
? "bg-red-500 text-white"
|
||||
: item.status === "주의"
|
||||
? "bg-yellow-500 text-white"
|
||||
: "bg-green-500 text-white"
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button size="sm" variant="outline">
|
||||
독촉
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 원가 분석 */}
|
||||
<TabsContent value="cost" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<PieChart className="h-6 w-6 text-primary" />
|
||||
<span>원가 구성 비율</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Tooltip />
|
||||
<RechartsPieChart>
|
||||
{costComposition.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</RechartsPieChart>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
{costComposition.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
</div>
|
||||
<span className="font-bold">{item.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<Calculator className="h-6 w-6 text-primary" />
|
||||
<span>건별 원가 분석</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{costAnalysis.slice(0, 3).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 bg-muted/50 rounded-lg border border-border/50"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">{item.orderNo}</span>
|
||||
<p className="font-bold text-sm">{item.product}</p>
|
||||
</div>
|
||||
<Badge
|
||||
className={
|
||||
item.profitRate >= 25
|
||||
? "bg-green-500 text-white"
|
||||
: item.profitRate >= 20
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-yellow-500 text-white"
|
||||
}
|
||||
>
|
||||
{item.profitRate}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">매출: </span>
|
||||
<span className="font-bold">
|
||||
{(item.salesAmount / 1000000).toFixed(1)}M
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">원가: </span>
|
||||
<span className="font-bold">
|
||||
{(item.totalCost / 1000000).toFixed(1)}M
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">이익: </span>
|
||||
<span className="font-bold text-green-600">
|
||||
{(item.profit / 1000000).toFixed(1)}M
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 손익 현황 */}
|
||||
<TabsContent value="profit" className="space-y-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<Banknote className="h-6 w-6 text-primary" />
|
||||
<span>월별 손익 현황</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={salesPurchaseData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="profit" stroke="#10B981" strokeWidth={2} name="순이익" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 입출금 내역 */}
|
||||
<TabsContent value="transactions" className="space-y-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<Building2 className="h-6 w-6 text-primary" />
|
||||
<span>거래처 입출금 내역</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<Badge className="bg-green-600 text-white mb-2">입금</Badge>
|
||||
<p className="font-bold">삼성전자</p>
|
||||
<p className="text-sm text-muted-foreground">2024-10-13 14:30</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-green-600">+25,000,000원</p>
|
||||
<p className="text-xs text-muted-foreground">제품 출하대금</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<Badge className="bg-red-600 text-white mb-2">출금</Badge>
|
||||
<p className="font-bold">포스코</p>
|
||||
<p className="text-sm text-muted-foreground">2024-10-13 11:20</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-red-600">-15,000,000원</p>
|
||||
<p className="text-xs text-muted-foreground">원자재 구매비</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,656 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Search, Plus, Download, Filter, Eye, Edit, Trash2, MessageSquare, FileText, Bell, Pin, Upload, Calendar } from "lucide-react";
|
||||
|
||||
interface Notice {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
department: string;
|
||||
date: string;
|
||||
views: number;
|
||||
isPinned: boolean;
|
||||
isImportant: boolean;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
version: string;
|
||||
author: string;
|
||||
uploadDate: string;
|
||||
downloads: number;
|
||||
category: string;
|
||||
accessLevel: string;
|
||||
}
|
||||
|
||||
interface Approval {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
requestor: string;
|
||||
department: string;
|
||||
requestDate: string;
|
||||
status: string;
|
||||
currentApprover: string;
|
||||
amount?: number;
|
||||
urgency: string;
|
||||
}
|
||||
|
||||
export function Board() {
|
||||
const [notices, setNotices] = useState<Notice[]>([
|
||||
{
|
||||
id: "N001",
|
||||
title: "2025년 1분기 생산 계획 공지",
|
||||
content: "2025년 1분기 생산 계획이 확정되었습니다. 각 부서는 계획에 따라 준비하시기 바랍니다.",
|
||||
author: "김경영",
|
||||
department: "경영팀",
|
||||
date: "2025-09-25",
|
||||
views: 45,
|
||||
isPinned: true,
|
||||
isImportant: true,
|
||||
category: "일반공지"
|
||||
},
|
||||
{
|
||||
id: "N002",
|
||||
title: "안전교육 실시 안내",
|
||||
content: "월간 안전교육을 다음 주에 실시합니다. 전직원 필수 참석 바랍니다.",
|
||||
author: "박안전",
|
||||
department: "안전관리팀",
|
||||
date: "2025-09-24",
|
||||
views: 32,
|
||||
isPinned: true,
|
||||
isImportant: false,
|
||||
category: "안전공지"
|
||||
},
|
||||
{
|
||||
id: "N003",
|
||||
title: "신규 설비 도입 완료",
|
||||
content: "CNC 머시닝센터 3호기 설치가 완료되었습니다.",
|
||||
author: "이설비",
|
||||
department: "설비팀",
|
||||
date: "2025-09-23",
|
||||
views: 28,
|
||||
isPinned: false,
|
||||
isImportant: false,
|
||||
category: "업무공지"
|
||||
},
|
||||
{
|
||||
id: "N004",
|
||||
title: "시스템 업데이트 예정",
|
||||
content: "SAM 시스템 정기 업데이트가 금요일 밤에 진행됩니다.",
|
||||
author: "최IT",
|
||||
department: "IT팀",
|
||||
date: "2025-09-22",
|
||||
views: 67,
|
||||
isPinned: false,
|
||||
isImportant: true,
|
||||
category: "시스템"
|
||||
}
|
||||
]);
|
||||
|
||||
const [documents, setDocuments] = useState<Document[]>([
|
||||
{
|
||||
id: "D001",
|
||||
title: "품질관리 매뉴얼 v2.1",
|
||||
description: "품질관리 표준 작업 절차서 및 체크리스트",
|
||||
fileName: "QMS_Manual_v2.1.pdf",
|
||||
fileSize: "2.4MB",
|
||||
version: "v2.1",
|
||||
author: "박품질",
|
||||
uploadDate: "2025-09-20",
|
||||
downloads: 23,
|
||||
category: "매뉴얼",
|
||||
accessLevel: "전체"
|
||||
},
|
||||
{
|
||||
id: "D002",
|
||||
title: "생산 공정도 템플릿",
|
||||
description: "표준 생산 공정도 작성 템플릿",
|
||||
fileName: "Process_Template.xlsx",
|
||||
fileSize: "156KB",
|
||||
version: "v1.3",
|
||||
author: "이생산",
|
||||
uploadDate: "2025-09-18",
|
||||
downloads: 15,
|
||||
category: "템플릿",
|
||||
accessLevel: "생산팀"
|
||||
},
|
||||
{
|
||||
id: "D003",
|
||||
title: "안전관리 체크리스트",
|
||||
description: "일일 안전점검 체크리스트 양식",
|
||||
fileName: "Safety_Checklist.pdf",
|
||||
fileSize: "890KB",
|
||||
version: "v1.0",
|
||||
author: "박안전",
|
||||
uploadDate: "2025-09-15",
|
||||
downloads: 41,
|
||||
category: "안전자료",
|
||||
accessLevel: "전체"
|
||||
}
|
||||
]);
|
||||
|
||||
const [approvals, setApprovals] = useState<Approval[]>([
|
||||
{
|
||||
id: "A001",
|
||||
title: "원자재 구매 품의",
|
||||
type: "구매품의",
|
||||
requestor: "최자재",
|
||||
department: "자재팀",
|
||||
requestDate: "2025-09-25",
|
||||
status: "결재대기",
|
||||
currentApprover: "김부장",
|
||||
amount: 15000000,
|
||||
urgency: "긴급"
|
||||
},
|
||||
{
|
||||
id: "A002",
|
||||
title: "설비 수리비 지출 결의",
|
||||
type: "지출결의",
|
||||
requestor: "정설비",
|
||||
department: "설비팀",
|
||||
requestDate: "2025-09-24",
|
||||
status: "승인완료",
|
||||
currentApprover: "-",
|
||||
amount: 2500000,
|
||||
urgency: "보통"
|
||||
},
|
||||
{
|
||||
id: "A003",
|
||||
title: "신제품 개발 품의",
|
||||
type: "개발품의",
|
||||
requestor: "김개발",
|
||||
department: "개발팀",
|
||||
requestDate: "2025-09-23",
|
||||
status: "검토중",
|
||||
currentApprover: "이이사",
|
||||
urgency: "보통"
|
||||
}
|
||||
]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("notices");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "일반공지": return "bg-blue-500";
|
||||
case "안전공지": return "bg-red-500";
|
||||
case "업무공지": return "bg-green-500";
|
||||
case "시스템": return "bg-purple-500";
|
||||
default: return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "결재대기": return "bg-yellow-500";
|
||||
case "승인완료": return "bg-green-500";
|
||||
case "반려": return "bg-red-500";
|
||||
case "검토중": return "bg-blue-500";
|
||||
default: return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyColor = (urgency: string) => {
|
||||
switch (urgency) {
|
||||
case "긴급": return "text-red-600";
|
||||
case "보통": return "text-yellow-600";
|
||||
case "낮음": return "text-green-600";
|
||||
default: return "text-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewItem = (item: any) => {
|
||||
setSelectedItem(item);
|
||||
setIsViewModalOpen(true);
|
||||
|
||||
// 조회수 증가 (공지사항의 경우)
|
||||
if (activeTab === "notices" && item.id) {
|
||||
setNotices(prev => prev.map(notice =>
|
||||
notice.id === item.id ? { ...notice, views: notice.views + 1 } : notice
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 space-y-6 md:space-y-8">
|
||||
{/* 헤더 */}
|
||||
<div className="samsung-card samsung-gradient-card relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-2 samsung-hero-gradient"></div>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pt-2">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-2">전자결재·협업</h1>
|
||||
<p className="text-muted-foreground text-lg">공지사항, 자료실, 전자결재 통합 관리 시스템</p>
|
||||
</div>
|
||||
<Button
|
||||
className="samsung-button w-full md:w-auto min-h-[48px]"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-3" />
|
||||
새 글 작성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 협업 대시보드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8 mb-8">
|
||||
<Card className="samsung-card samsung-gradient-card hover:scale-105 hover:-translate-y-2 transition-all duration-500 border-0 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-sm font-bold text-muted-foreground uppercase tracking-wide">신규 공지</CardTitle>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center samsung-shadow group-hover:scale-110 transition-transform duration-300">
|
||||
<Bell className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-black text-blue-600 mb-3">
|
||||
{notices.filter(n => {
|
||||
const today = new Date();
|
||||
const noticeDate = new Date(n.date);
|
||||
const diffTime = Math.abs(today.getTime() - noticeDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays <= 7;
|
||||
}).length}건
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl px-3 py-2 font-semibold">
|
||||
최근 7일 내 공지
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card samsung-gradient-card hover:scale-105 hover:-translate-y-2 transition-all duration-500 border-0 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-sm font-bold text-muted-foreground uppercase tracking-wide">자료실</CardTitle>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center samsung-shadow group-hover:scale-110 transition-transform duration-300">
|
||||
<FileText className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-black text-green-600 mb-3">{documents.length}개</div>
|
||||
<p className="text-sm text-muted-foreground bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl px-3 py-2 font-semibold">
|
||||
등록된 문서 수
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card samsung-gradient-card hover:scale-105 hover:-translate-y-2 transition-all duration-500 border-0 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-sm font-bold text-muted-foreground uppercase tracking-wide">결재 대기</CardTitle>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-orange-500 to-red-500 rounded-2xl flex items-center justify-center samsung-shadow group-hover:scale-110 transition-transform duration-300">
|
||||
<MessageSquare className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-black text-orange-600 mb-3">
|
||||
{approvals.filter(a => a.status === "결재대기").length}건
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground bg-gradient-to-r from-orange-50 to-red-50 rounded-xl px-3 py-2 font-semibold">
|
||||
처리 대기중인 결재
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card samsung-gradient-card hover:scale-105 hover:-translate-y-2 transition-all duration-500 border-0 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-sm font-bold text-muted-foreground uppercase tracking-wide">긴급 건</CardTitle>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-pink-500 rounded-2xl flex items-center justify-center samsung-shadow group-hover:scale-110 transition-transform duration-300">
|
||||
<Calendar className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-black text-red-600 mb-3">
|
||||
{approvals.filter(a => a.urgency === "긴급").length}건
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground bg-gradient-to-r from-red-50 to-pink-50 rounded-xl px-3 py-2 font-semibold">
|
||||
긴급 처리 필요
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="grid w-full grid-cols-3 min-w-[400px] samsung-glass h-14 p-2 rounded-2xl">
|
||||
<TabsTrigger value="notices" className="flex items-center space-x-3 rounded-xl h-10 transition-all duration-300 data-[state=active]:samsung-hero-gradient data-[state=active]:text-white">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="hidden sm:inline font-semibold">공지사항</span>
|
||||
<span className="sm:hidden font-semibold">공지</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="documents" className="flex items-center space-x-3 rounded-xl h-10 transition-all duration-300 data-[state=active]:samsung-hero-gradient data-[state=active]:text-white">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span className="hidden sm:inline font-semibold">자료실</span>
|
||||
<span className="sm:hidden font-semibold">자료</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="approvals" className="flex items-center space-x-3 rounded-xl h-10 transition-all duration-300 data-[state=active]:samsung-hero-gradient data-[state=active]:text-white">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span className="hidden sm:inline font-semibold">전자결재</span>
|
||||
<span className="sm:hidden font-semibold">결재</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="notices" className="space-y-6">
|
||||
<Card className="samsung-card samsung-gradient-card border-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<CardTitle className="text-2xl font-bold text-foreground">📢 공지사항</CardTitle>
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center space-y-3 md:space-y-0 md:space-x-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-muted-foreground h-5 w-5" />
|
||||
<Input placeholder="제목 검색..." className="pl-12 w-full md:w-80 samsung-input border-0" />
|
||||
</div>
|
||||
<Select defaultValue="all">
|
||||
<SelectTrigger className="w-full md:w-40 samsung-input border-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="일반공지">일반공지</SelectItem>
|
||||
<SelectItem value="안전공지">안전공지</SelectItem>
|
||||
<SelectItem value="업무공지">업무공지</SelectItem>
|
||||
<SelectItem value="시스템">시스템</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[60px]">상태</TableHead>
|
||||
<TableHead className="min-w-[80px]">분류</TableHead>
|
||||
<TableHead className="min-w-[200px]">제목</TableHead>
|
||||
<TableHead className="min-w-[80px]">작성자</TableHead>
|
||||
<TableHead className="min-w-[100px]">부서</TableHead>
|
||||
<TableHead className="min-w-[100px]">작성일</TableHead>
|
||||
<TableHead className="min-w-[60px]">조회</TableHead>
|
||||
<TableHead className="min-w-[80px]">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{notices.map((notice) => (
|
||||
<TableRow key={notice.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
{notice.isPinned && (
|
||||
<Pin className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
{notice.isImportant && (
|
||||
<span className="text-red-500 text-xs">●</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${getCategoryColor(notice.category)} text-white text-xs`}>
|
||||
{notice.category}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => handleViewItem(notice)}
|
||||
className="text-left hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{notice.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{notice.author}</TableCell>
|
||||
<TableCell>{notice.department}</TableCell>
|
||||
<TableCell>{notice.date}</TableCell>
|
||||
<TableCell>{notice.views}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleViewItem(notice)}
|
||||
className="p-3 rounded-xl hover:scale-105 transition-all duration-300 border-primary/20 hover:bg-primary/10"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="p-3 rounded-xl hover:scale-105 transition-all duration-300 border-primary/20 hover:bg-primary/10">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents" className="space-y-6">
|
||||
<Card className="samsung-card samsung-gradient-card border-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<CardTitle className="text-2xl font-bold text-foreground">📁 자료실</CardTitle>
|
||||
<Button className="samsung-button w-full md:w-auto min-h-[48px]">
|
||||
<Upload className="h-5 w-5 mr-3" />
|
||||
파일 업로드
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{documents.map((doc) => (
|
||||
<Card key={doc.id} className="samsung-card samsung-gradient-card border-0 p-6 hover:scale-105 hover:-translate-y-2 transition-all duration-500 group">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-base mb-2 text-foreground">{doc.title}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">{doc.description}</p>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground bg-muted/50 rounded-xl px-3 py-2">
|
||||
<span>{doc.fileName}</span>
|
||||
<span>•</span>
|
||||
<span className="font-semibold">{doc.fileSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm font-semibold px-3 py-1 rounded-xl">
|
||||
{doc.version}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm text-muted-foreground mb-4">
|
||||
<span className="font-semibold">{doc.author}</span>
|
||||
<span>{doc.uploadDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">다운로드: <span className="font-bold text-primary">{doc.downloads}</span>회</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="p-3 rounded-xl hover:scale-105 transition-all duration-300 border-primary/20 hover:bg-primary/10">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="p-3 rounded-xl hover:scale-105 transition-all duration-300 border-primary/20 hover:bg-primary/10">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="approvals" className="space-y-6">
|
||||
<Card className="samsung-card samsung-gradient-card border-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold text-foreground">📋 전자결재</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[100px]">결재번호</TableHead>
|
||||
<TableHead className="min-w-[150px]">제목</TableHead>
|
||||
<TableHead className="min-w-[80px]">유형</TableHead>
|
||||
<TableHead className="min-w-[80px]">기안자</TableHead>
|
||||
<TableHead className="min-w-[80px]">부서</TableHead>
|
||||
<TableHead className="min-w-[100px]">기안일</TableHead>
|
||||
<TableHead className="min-w-[80px]">상태</TableHead>
|
||||
<TableHead className="min-w-[100px]">현재결재자</TableHead>
|
||||
<TableHead className="min-w-[80px]">긴급도</TableHead>
|
||||
<TableHead className="min-w-[80px]">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{approvals.map((approval) => (
|
||||
<TableRow key={approval.id}>
|
||||
<TableCell className="font-medium">{approval.id}</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => handleViewItem(approval)}
|
||||
className="text-left hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{approval.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{approval.type}</TableCell>
|
||||
<TableCell>{approval.requestor}</TableCell>
|
||||
<TableCell>{approval.department}</TableCell>
|
||||
<TableCell>{approval.requestDate}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${getStatusColor(approval.status)} text-white text-xs`}>
|
||||
{approval.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{approval.currentApprover}</TableCell>
|
||||
<TableCell>
|
||||
<span className={getUrgencyColor(approval.urgency)}>
|
||||
{approval.urgency}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleViewItem(approval)}
|
||||
className="p-3 rounded-xl hover:scale-105 transition-all duration-300 border-primary/20 hover:bg-primary/10"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 상세보기 모달 */}
|
||||
<Dialog open={isViewModalOpen} onOpenChange={setIsViewModalOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto samsung-glass rounded-3xl border-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{activeTab === "notices" && "공지사항 상세"}
|
||||
{activeTab === "documents" && "문서 상세"}
|
||||
{activeTab === "approvals" && "결재 상세"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{activeTab === "notices" && "공지사항의 상세 내용을 확인합니다."}
|
||||
{activeTab === "documents" && "문서의 상세 정보를 확인합니다."}
|
||||
{activeTab === "approvals" && "결재 건의 상세 내용을 확인합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedItem && (
|
||||
<div className="space-y-6">
|
||||
{activeTab === "notices" && (
|
||||
<div>
|
||||
<div className="border-b pb-4 mb-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h2 className="text-xl font-bold">{selectedItem.title}</h2>
|
||||
{selectedItem.isPinned && <Pin className="h-4 w-4 text-red-500" />}
|
||||
{selectedItem.isImportant && <span className="text-red-500">●</span>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<span>작성자: {selectedItem.author}</span>
|
||||
<span>부서: {selectedItem.department}</span>
|
||||
<span>작성일: {selectedItem.date}</span>
|
||||
<span>조회수: {selectedItem.views}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none">
|
||||
<p>{selectedItem.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "approvals" && (
|
||||
<div>
|
||||
<div className="border-b pb-4 mb-4">
|
||||
<h2 className="text-xl font-bold mb-2">{selectedItem.title}</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">결재번호:</span> {selectedItem.id}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">유형:</span> {selectedItem.type}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">기안자:</span> {selectedItem.requestor}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">부서:</span> {selectedItem.department}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">기안일:</span> {selectedItem.requestDate}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">상태:</span>
|
||||
<Badge className={`ml-2 ${getStatusColor(selectedItem.status)} text-white text-xs`}>
|
||||
{selectedItem.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedItem.amount && (
|
||||
<div>
|
||||
<span className="text-gray-600">금액:</span> {selectedItem.amount.toLocaleString()}원
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-600">긴급도:</span>
|
||||
<span className={getUrgencyColor(selectedItem.urgency)}> {selectedItem.urgency}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedItem.status === "결재대기" && (
|
||||
<div className="flex space-x-4">
|
||||
<Button className="samsung-button bg-gradient-to-r from-green-500 to-emerald-600">승인</Button>
|
||||
<Button variant="outline" className="samsung-button-secondary border-red-500 text-red-600 hover:bg-red-50">반려</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setIsViewModalOpen(false)} className="samsung-button-secondary">닫기</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,659 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Code,
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
FolderTree,
|
||||
Tag,
|
||||
CheckCircle,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
|
||||
interface CodeGroup {
|
||||
id: string;
|
||||
groupCode: string;
|
||||
groupName: string;
|
||||
description: string;
|
||||
itemCount: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface CodeItem {
|
||||
id: string;
|
||||
groupCode: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function CodeManagement() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isAddGroupDialogOpen, setIsAddGroupDialogOpen] = useState(false);
|
||||
const [isAddItemDialogOpen, setIsAddItemDialogOpen] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState("");
|
||||
const [isEditGroupDialogOpen, setIsEditGroupDialogOpen] = useState(false);
|
||||
const [isEditItemDialogOpen, setIsEditItemDialogOpen] = useState(false);
|
||||
const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false);
|
||||
const [isDeleteItemDialogOpen, setIsDeleteItemDialogOpen] = useState(false);
|
||||
const [selectedCodeGroup, setSelectedCodeGroup] = useState<CodeGroup | null>(null);
|
||||
const [selectedCodeItem, setSelectedCodeItem] = useState<CodeItem | null>(null);
|
||||
|
||||
const [codeGroups, setCodeGroups] = useState<CodeGroup[]>([
|
||||
{
|
||||
id: "1",
|
||||
groupCode: "ITEM_TYPE",
|
||||
groupName: "품목 유형",
|
||||
description: "원자재, 부자재, 반제품, 완제품 구분",
|
||||
itemCount: 4,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
groupCode: "UNIT",
|
||||
groupName: "단위",
|
||||
description: "재고 및 거래 단위",
|
||||
itemCount: 8,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
groupCode: "PROCESS_STATUS",
|
||||
groupName: "공정 상태",
|
||||
description: "생산 공정 진행 상태",
|
||||
itemCount: 6,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
groupCode: "QUALITY_GRADE",
|
||||
groupName: "품질 등급",
|
||||
description: "제품 품질 등급 분류",
|
||||
itemCount: 3,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
groupCode: "CUSTOMER_TYPE",
|
||||
groupName: "고객 유형",
|
||||
description: "고객 분류 코드",
|
||||
itemCount: 5,
|
||||
isActive: true
|
||||
}
|
||||
]);
|
||||
|
||||
const [codeItems, setCodeItems] = useState<CodeItem[]>([
|
||||
// 품목 유형
|
||||
{ id: "1", groupCode: "ITEM_TYPE", itemCode: "RAW", itemName: "원자재", description: "가공되지 않은 원재료", sortOrder: 1, isActive: true },
|
||||
{ id: "2", groupCode: "ITEM_TYPE", itemCode: "SUB", itemName: "부자재", description: "보조 자재", sortOrder: 2, isActive: true },
|
||||
{ id: "3", groupCode: "ITEM_TYPE", itemCode: "SEMI", itemName: "반제품", description: "중간 생산품", sortOrder: 3, isActive: true },
|
||||
{ id: "4", groupCode: "ITEM_TYPE", itemCode: "FINISHED", itemName: "완제품", description: "최종 제품", sortOrder: 4, isActive: true },
|
||||
|
||||
// 단위
|
||||
{ id: "5", groupCode: "UNIT", itemCode: "EA", itemName: "개", description: "낱개 단위", sortOrder: 1, isActive: true },
|
||||
{ id: "6", groupCode: "UNIT", itemCode: "KG", itemName: "킬로그램", description: "무게 단위", sortOrder: 2, isActive: true },
|
||||
{ id: "7", groupCode: "UNIT", itemCode: "M", itemName: "미터", description: "길이 단위", sortOrder: 3, isActive: true },
|
||||
{ id: "8", groupCode: "UNIT", itemCode: "BOX", itemName: "박스", description: "포장 단위", sortOrder: 4, isActive: true },
|
||||
|
||||
// 공정 상태
|
||||
{ id: "9", groupCode: "PROCESS_STATUS", itemCode: "READY", itemName: "준비", description: "작업 준비 중", sortOrder: 1, isActive: true },
|
||||
{ id: "10", groupCode: "PROCESS_STATUS", itemCode: "PROGRESS", itemName: "진행", description: "작업 진행 중", sortOrder: 2, isActive: true },
|
||||
{ id: "11", groupCode: "PROCESS_STATUS", itemCode: "COMPLETE", itemName: "완료", description: "작업 완료", sortOrder: 3, isActive: true },
|
||||
{ id: "12", groupCode: "PROCESS_STATUS", itemCode: "HOLD", itemName: "보류", description: "일시 중단", sortOrder: 4, isActive: true },
|
||||
|
||||
// 품질 등급
|
||||
{ id: "13", groupCode: "QUALITY_GRADE", itemCode: "A", itemName: "A등급", description: "최상급", sortOrder: 1, isActive: true },
|
||||
{ id: "14", groupCode: "QUALITY_GRADE", itemCode: "B", itemName: "B등급", description: "우수", sortOrder: 2, isActive: true },
|
||||
{ id: "15", groupCode: "QUALITY_GRADE", itemCode: "C", itemName: "C등급", description: "양호", sortOrder: 3, isActive: true },
|
||||
]);
|
||||
|
||||
const handleEditGroup = (group: CodeGroup) => {
|
||||
setSelectedCodeGroup(group);
|
||||
setIsEditGroupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteGroup = (group: CodeGroup) => {
|
||||
setSelectedCodeGroup(group);
|
||||
setIsDeleteGroupDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditItem = (item: CodeItem) => {
|
||||
setSelectedCodeItem(item);
|
||||
setIsEditItemDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (item: CodeItem) => {
|
||||
setSelectedCodeItem(item);
|
||||
setIsDeleteItemDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteGroup = () => {
|
||||
if (selectedCodeGroup) {
|
||||
setCodeGroups(codeGroups.filter(g => g.id !== selectedCodeGroup.id));
|
||||
setIsDeleteGroupDialogOpen(false);
|
||||
setSelectedCodeGroup(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteItem = () => {
|
||||
if (selectedCodeItem) {
|
||||
setCodeItems(codeItems.filter(i => i.id !== selectedCodeItem.id));
|
||||
setIsDeleteItemDialogOpen(false);
|
||||
setSelectedCodeItem(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEditGroup = () => {
|
||||
if (selectedCodeGroup) {
|
||||
setCodeGroups(codeGroups.map(g =>
|
||||
g.id === selectedCodeGroup.id ? selectedCodeGroup : g
|
||||
));
|
||||
setIsEditGroupDialogOpen(false);
|
||||
setSelectedCodeGroup(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEditItem = () => {
|
||||
if (selectedCodeItem) {
|
||||
setCodeItems(codeItems.map(i =>
|
||||
i.id === selectedCodeItem.id ? selectedCodeItem : i
|
||||
));
|
||||
setIsEditItemDialogOpen(false);
|
||||
setSelectedCodeItem(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredGroups = codeGroups.filter(group =>
|
||||
group.groupName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
group.groupCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredItems = selectedGroup
|
||||
? codeItems.filter(item => item.groupCode === selectedGroup)
|
||||
: codeItems;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-teal-100 rounded-lg flex items-center justify-center">
|
||||
<Code className="h-6 w-6 text-teal-600" />
|
||||
</div>
|
||||
코드 관리
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">시스템 공통 코드 및 분류 관리</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Dialog open={isAddGroupDialogOpen} onOpenChange={setIsAddGroupDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<FolderTree className="h-4 w-4 mr-2" />
|
||||
코드 그룹 추가
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>신규 코드 그룹 등록</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 코드 그룹을 생성합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹 코드 *</Label>
|
||||
<Input placeholder="예: ITEM_TYPE" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명 *</Label>
|
||||
<Input placeholder="예: 품목 유형" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input placeholder="코드 그룹 설명" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddGroupDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-teal-600 hover:bg-teal-700">
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAddItemDialogOpen} onOpenChange={setIsAddItemDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-teal-600 hover:bg-teal-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
코드 항목 추가
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>신규 코드 항목 등록</DialogTitle>
|
||||
<DialogDescription>
|
||||
코드 그룹에 새로운 항목을 추가합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>코드 그룹 *</Label>
|
||||
<Input placeholder="ITEM_TYPE" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>항목 코드 *</Label>
|
||||
<Input placeholder="RAW" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>항목명 *</Label>
|
||||
<Input placeholder="원자재" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input placeholder="코드 설명" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>정렬 순서</Label>
|
||||
<Input type="number" defaultValue="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsAddItemDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-teal-600 hover:bg-teal-700">
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="border-l-4 border-l-teal-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">코드 그룹</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-teal-600">{codeGroups.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">전체 그룹 수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">코드 항목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">{codeItems.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">전체 코드 수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">활성화</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{codeGroups.filter(g => g.isActive).length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">사용 중인 그룹</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">평균 항목 수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{Math.round(codeItems.length / codeGroups.length)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">그룹당 평균</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs defaultValue="groups" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="groups">
|
||||
<FolderTree className="h-4 w-4 mr-2" />
|
||||
코드 그룹
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items">
|
||||
<Tag className="h-4 w-4 mr-2" />
|
||||
코드 항목
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 코드 그룹 탭 */}
|
||||
<TabsContent value="groups" className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="그룹명 또는 그룹 코드로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>코드 그룹 목록 ({filteredGroups.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">그룹 코드</TableHead>
|
||||
<TableHead>그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="text-center">항목 수</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredGroups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-mono text-sm font-medium">
|
||||
{group.groupCode}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{group.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{group.itemCount}개</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{group.isActive ? (
|
||||
<Badge className="bg-green-500 text-white">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
활성
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gray-500 text-white">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
비활성
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 코드 항목 탭 */}
|
||||
<TabsContent value="items" className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="코드 또는 항목명으로 검색..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={selectedGroup ? "default" : "outline"}
|
||||
onClick={() => setSelectedGroup("")}
|
||||
>
|
||||
전체 보기
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>코드 항목 목록 ({filteredItems.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-32">그룹 코드</TableHead>
|
||||
<TableHead className="w-32">항목 코드</TableHead>
|
||||
<TableHead>항목명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="text-center">순서</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{item.groupCode}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm font-medium text-teal-600">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.itemName}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{item.sortOrder}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.isActive ? (
|
||||
<Badge className="bg-green-500 text-white text-xs">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
활성
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gray-500 text-white text-xs">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
비활성
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 코드 그룹 수정 다이얼로그 */}
|
||||
<Dialog open={isEditGroupDialogOpen} onOpenChange={setIsEditGroupDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>코드 그룹 수정</DialogTitle>
|
||||
<DialogDescription>
|
||||
코드 그룹 정보를 수정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedCodeGroup && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>그룹 코드</Label>
|
||||
<Input
|
||||
value={selectedCodeGroup.groupCode}
|
||||
onChange={(e) => setSelectedCodeGroup({...selectedCodeGroup, groupCode: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>그룹명</Label>
|
||||
<Input
|
||||
value={selectedCodeGroup.groupName}
|
||||
onChange={(e) => setSelectedCodeGroup({...selectedCodeGroup, groupName: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={selectedCodeGroup.description}
|
||||
onChange={(e) => setSelectedCodeGroup({...selectedCodeGroup, description: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsEditGroupDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-teal-600 hover:bg-teal-700" onClick={saveEditGroup}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 코드 항목 수정 다이얼로그 */}
|
||||
<Dialog open={isEditItemDialogOpen} onOpenChange={setIsEditItemDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>코드 항목 수정</DialogTitle>
|
||||
<DialogDescription>
|
||||
코드 항목 정보를 수정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedCodeItem && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>항목 코드</Label>
|
||||
<Input
|
||||
value={selectedCodeItem.itemCode}
|
||||
onChange={(e) => setSelectedCodeItem({...selectedCodeItem, itemCode: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>항목명</Label>
|
||||
<Input
|
||||
value={selectedCodeItem.itemName}
|
||||
onChange={(e) => setSelectedCodeItem({...selectedCodeItem, itemName: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={selectedCodeItem.description}
|
||||
onChange={(e) => setSelectedCodeItem({...selectedCodeItem, description: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>정렬 순서</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedCodeItem.sortOrder}
|
||||
onChange={(e) => setSelectedCodeItem({...selectedCodeItem, sortOrder: parseInt(e.target.value)})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsEditItemDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-teal-600 hover:bg-teal-700" onClick={saveEditItem}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 코드 그룹 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteGroupDialogOpen} onOpenChange={setIsDeleteGroupDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>코드 그룹 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{selectedCodeGroup?.groupName}" 그룹을 삭제하시겠습니까?<br/>
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteGroup}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 코드 항목 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteItemDialogOpen} onOpenChange={setIsDeleteItemDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>코드 항목 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{selectedCodeItem?.itemName}" 항목을 삭제하시겠습니까?<br/>
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteItem}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Building2, Mail, Phone, Briefcase, MessageSquare } from "lucide-react";
|
||||
|
||||
interface ContactModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function ContactModal({ isOpen, onClose, title, description }: ContactModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
industry: "",
|
||||
message: ""
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// localStorage에 리드 저장
|
||||
const leadId = `lead_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const newLead = {
|
||||
id: leadId,
|
||||
...formData,
|
||||
status: "pending",
|
||||
submittedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const existingLeads = JSON.parse(localStorage.getItem("salesLeads") || "[]");
|
||||
existingLeads.push(newLead);
|
||||
localStorage.setItem("salesLeads", JSON.stringify(existingLeads));
|
||||
|
||||
console.log("Form submitted:", formData);
|
||||
alert("데모 요청이 접수되었습니다. 1영업일 내에 연락드리겠습니다.");
|
||||
onClose();
|
||||
|
||||
// 폼 초기화
|
||||
setFormData({
|
||||
name: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
industry: "",
|
||||
message: ""
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{title || "데모 요청하기"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
{description || "귀사의 정보를 입력해주시면 전문 영업사원이 1영업일 내에 연락드립니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
|
||||
{/* 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-semibold flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-blue-600" />
|
||||
담당자 성함 *
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 회사명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company" className="text-sm font-semibold flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-blue-600" />
|
||||
회사명 *
|
||||
</Label>
|
||||
<Input
|
||||
id="company"
|
||||
placeholder="(주)샘플컴퍼니"
|
||||
value={formData.company}
|
||||
onChange={(e) => handleChange("company", e.target.value)}
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이메일 & 연락처 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-semibold flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-blue-600" />
|
||||
이메일 *
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="contact@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-sm font-semibold flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-blue-600" />
|
||||
연락처 *
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange("phone", e.target.value)}
|
||||
required
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 산업 분야 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="industry" className="text-sm font-semibold flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4 text-blue-600" />
|
||||
산업 분야 *
|
||||
</Label>
|
||||
<Select value={formData.industry} onValueChange={(value) => handleChange("industry", value)} required>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="산업 분야를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="automotive">자동차 부품</SelectItem>
|
||||
<SelectItem value="electronics">전자/전기</SelectItem>
|
||||
<SelectItem value="machinery">기계/설비</SelectItem>
|
||||
<SelectItem value="food">식품 가공</SelectItem>
|
||||
<SelectItem value="chemical">화학/제약</SelectItem>
|
||||
<SelectItem value="plastic">플라스틱/고무</SelectItem>
|
||||
<SelectItem value="metal">금속 가공</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 요청사항 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message" className="text-sm font-semibold flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-blue-600" />
|
||||
요청사항 (선택)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
placeholder="궁금하신 점이나 특별히 보고 싶은 기능이 있으시면 작성해주세요."
|
||||
value={formData.message}
|
||||
onChange={(e) => handleChange("message", e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1 h-12"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-semibold"
|
||||
>
|
||||
데모 요청하기
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useUserRole } from "@/hooks/useUserRole";
|
||||
import { Suspense } from "react";
|
||||
import { MainDashboard } from "./MainDashboard";
|
||||
|
||||
// ✅ Lazy Loading: 모든 역할별 대시보드를 개별 컴포넌트로 분리
|
||||
const CEODashboard = lazy(() =>
|
||||
import("./CEODashboard").then(m => ({ default: m.CEODashboard }))
|
||||
);
|
||||
|
||||
const ProductionManagerDashboard = lazy(() =>
|
||||
import("./ProductionManagerDashboard").then(m => ({ default: m.ProductionManagerDashboard }))
|
||||
);
|
||||
|
||||
const WorkerDashboard = lazy(() =>
|
||||
import("./WorkerDashboard").then(m => ({ default: m.WorkerDashboard }))
|
||||
);
|
||||
|
||||
const SystemAdminDashboard = lazy(() =>
|
||||
import("./SystemAdminDashboard").then(m => ({ default: m.SystemAdminDashboard }))
|
||||
);
|
||||
/**
|
||||
* Dashboard - 통합 대시보드 컴포넌트
|
||||
*
|
||||
* 사용자 역할과 메뉴는 백엔드(PHP)에서 관리하며,
|
||||
* 프론트엔드는 단일 통합 대시보드만 제공합니다.
|
||||
*
|
||||
* - 역할별 데이터 필터링: 백엔드 API에서 처리
|
||||
* - 메뉴 구조: 로그인 시 받은 user.menu 데이터 사용
|
||||
* - 권한 제어: 백엔드에서 역할에 따라 데이터 제한
|
||||
*/
|
||||
|
||||
// 공통 로딩 컴포넌트
|
||||
const DashboardLoading = () => (
|
||||
@@ -31,45 +25,10 @@ const DashboardLoading = () => (
|
||||
);
|
||||
|
||||
export function Dashboard() {
|
||||
const userRole = useUserRole();
|
||||
|
||||
// 역할별 대시보드 라우팅 with Suspense
|
||||
if (userRole === "CEO") {
|
||||
return (
|
||||
<Suspense fallback={<DashboardLoading />}>
|
||||
<CEODashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (userRole === "ProductionManager") {
|
||||
return (
|
||||
<Suspense fallback={<DashboardLoading />}>
|
||||
<ProductionManagerDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (userRole === "Worker") {
|
||||
return (
|
||||
<Suspense fallback={<DashboardLoading />}>
|
||||
<WorkerDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (userRole === "SystemAdmin") {
|
||||
return (
|
||||
<Suspense fallback={<DashboardLoading />}>
|
||||
<SystemAdminDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Sales 역할 (기본 대시보드)
|
||||
console.log('🎨 Dashboard component rendering...');
|
||||
return (
|
||||
<Suspense fallback={<DashboardLoading />}>
|
||||
<CEODashboard />
|
||||
<MainDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Mail,
|
||||
Phone,
|
||||
User,
|
||||
Building2,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
|
||||
interface DemoRequestPageProps {
|
||||
onNavigateToLanding: () => void;
|
||||
onRequestComplete: () => void;
|
||||
}
|
||||
|
||||
export function DemoRequestPage({ onNavigateToLanding, onRequestComplete }: DemoRequestPageProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
industry: "",
|
||||
requirements: ""
|
||||
});
|
||||
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// 데모 리드 데이터를 로컬스토리지에 저장
|
||||
const existingLeads = JSON.parse(localStorage.getItem('demoLeads') || '[]');
|
||||
const newLead = {
|
||||
id: Date.now().toString(),
|
||||
...formData,
|
||||
status: "신규",
|
||||
createdAt: new Date().toISOString(),
|
||||
assignedTo: null,
|
||||
demoLink: null
|
||||
};
|
||||
|
||||
localStorage.setItem('demoLeads', JSON.stringify([...existingLeads, newLead]));
|
||||
|
||||
// 제출 시뮬레이션
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(true);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<Card className="max-w-2xl w-full p-12 text-center clean-shadow-xl">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-foreground mb-4">
|
||||
데모 요청이 접수되었습니다!
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-lg mb-8">
|
||||
<strong className="text-foreground">{formData.name}</strong>님, 요청해주셔서 감사합니다.
|
||||
</p>
|
||||
|
||||
<div className="bg-primary/10 rounded-2xl p-6 mb-8">
|
||||
<div className="flex items-center justify-center space-x-3 mb-3">
|
||||
<Clock className="w-6 h-6 text-primary" />
|
||||
<h3 className="font-bold text-foreground text-xl">영업 담당자가 곧 연락드립니다</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
영업 담당자가 귀사의 요구사항을 확인한 후<br />
|
||||
<strong className="text-primary">1영업일 이내</strong>에 연락드려 맞춤형 데모를 제공해드리겠습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-8">
|
||||
<div className="bg-accent/50 rounded-xl p-4">
|
||||
<Mail className="w-5 h-5 text-primary mb-2 mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">이메일</p>
|
||||
<p className="font-semibold text-foreground">{formData.email}</p>
|
||||
</div>
|
||||
<div className="bg-accent/50 rounded-xl p-4">
|
||||
<Phone className="w-5 h-5 text-primary mb-2 mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">연락처</p>
|
||||
<p className="font-semibold text-foreground">{formData.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
onClick={onNavigateToLanding}
|
||||
className="rounded-xl px-8 py-6 bg-primary hover:bg-primary/90"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
홈으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<Card className="max-w-3xl w-full p-8 md:p-12 clean-shadow-xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onNavigateToLanding}
|
||||
className="mb-6 rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
돌아가기
|
||||
</Button>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<Badge className="mb-4 bg-primary/10 text-primary border-primary/20 rounded-full px-4 py-2">
|
||||
<Sparkles className="w-4 h-4 mr-2 inline" />
|
||||
데모 신청
|
||||
</Badge>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
||||
SAM 솔루션 데모 요청
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
귀사의 제조 환경에 최적화된 맞춤형 데모를 제공해드립니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-base flex items-center">
|
||||
<User className="w-4 h-4 mr-2 text-primary" />
|
||||
이름 *
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="홍길동"
|
||||
required
|
||||
className="clean-input border-0 bg-input-background/60 text-base py-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company" className="text-base flex items-center">
|
||||
<Building2 className="w-4 h-4 mr-2 text-primary" />
|
||||
회사명 *
|
||||
</Label>
|
||||
<Input
|
||||
id="company"
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleChange}
|
||||
placeholder="㈜ 제조기업"
|
||||
required
|
||||
className="clean-input border-0 bg-input-background/60 text-base py-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-base flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-primary" />
|
||||
이메일 *
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="example@company.com"
|
||||
required
|
||||
className="clean-input border-0 bg-input-background/60 text-base py-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-base flex items-center">
|
||||
<Phone className="w-4 h-4 mr-2 text-primary" />
|
||||
연락처 *
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="010-1234-5678"
|
||||
required
|
||||
className="clean-input border-0 bg-input-background/60 text-base py-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="industry" className="text-base">
|
||||
산업 분야 *
|
||||
</Label>
|
||||
<Input
|
||||
id="industry"
|
||||
name="industry"
|
||||
value={formData.industry}
|
||||
onChange={handleChange}
|
||||
placeholder="예: 자동차 부품 제조, 식품 가공, 전자 제품 조립 등"
|
||||
required
|
||||
className="clean-input border-0 bg-input-background/60 text-base py-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requirements" className="text-base">
|
||||
관심 기능 및 요구사항
|
||||
</Label>
|
||||
<Textarea
|
||||
id="requirements"
|
||||
name="requirements"
|
||||
value={formData.requirements}
|
||||
onChange={handleChange}
|
||||
placeholder="관심 있는 기능이나 해결하고 싶은 문제를 자유롭게 작성해주세요"
|
||||
rows={5}
|
||||
className="clean-input border-0 bg-input-background/60 text-base resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-accent/50 rounded-xl p-4 flex items-start space-x-3">
|
||||
<Clock className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-foreground mb-1">빠른 응답 보장</p>
|
||||
<p className="text-muted-foreground">
|
||||
영업 담당자가 귀하의 요청을 확인 후 1영업일 이내에 연락드립니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-xl py-7 text-lg bg-primary hover:bg-primary/90 clean-shadow-lg"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
전송 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5 mr-2" />
|
||||
데모 요청하기
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Pen, Square, Circle, Type, Minus, Eraser, Trash2, Undo2, Save } from "lucide-react";
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave?: (imageData: string) => void;
|
||||
initialImage?: string;
|
||||
}
|
||||
|
||||
type Tool = "pen" | "line" | "rect" | "circle" | "text" | "eraser";
|
||||
|
||||
export function DrawingCanvas({ open, onOpenChange, onSave, initialImage }: DrawingCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [tool, setTool] = useState<Tool>("pen");
|
||||
const [color, setColor] = useState("#000000");
|
||||
const [lineWidth, setLineWidth] = useState(2);
|
||||
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
|
||||
const [history, setHistory] = useState<ImageData[]>([]);
|
||||
const [historyStep, setHistoryStep] = useState(-1);
|
||||
|
||||
const colors = [
|
||||
"#000000", "#FF0000", "#00FF00", "#0000FF",
|
||||
"#FFFF00", "#FF00FF", "#00FFFF", "#FFA500",
|
||||
"#800080", "#FFC0CB"
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (open && canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
// 캔버스 초기화
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 초기 이미지가 있으면 로드
|
||||
if (initialImage) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
saveToHistory();
|
||||
};
|
||||
img.src = initialImage;
|
||||
} else {
|
||||
saveToHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const saveToHistory = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const newHistory = history.slice(0, historyStep + 1);
|
||||
newHistory.push(imageData);
|
||||
setHistory(newHistory);
|
||||
setHistoryStep(newHistory.length - 1);
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (historyStep > 0) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const newStep = historyStep - 1;
|
||||
ctx.putImageData(history[newStep], 0, 0);
|
||||
setHistoryStep(newStep);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
saveToHistory();
|
||||
};
|
||||
|
||||
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
};
|
||||
|
||||
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
setStartPos(pos);
|
||||
setIsDrawing(true);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
if (tool === "pen" || tool === "eraser") {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pos.x, pos.y);
|
||||
if (tool === "eraser") {
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
} else {
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const pos = getMousePos(e);
|
||||
|
||||
if (tool === "pen" || tool === "eraser") {
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
// 도형 그리기: 임시로 표시하기 위해 이전 상태 복원
|
||||
if (historyStep >= 0) {
|
||||
ctx.putImageData(history[historyStep], 0, 0);
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
|
||||
if (tool === "line") {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startPos.x, startPos.y);
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
} else if (tool === "rect") {
|
||||
const width = pos.x - startPos.x;
|
||||
const height = pos.y - startPos.y;
|
||||
ctx.strokeRect(startPos.x, startPos.y, width, height);
|
||||
} else if (tool === "circle") {
|
||||
const radius = Math.sqrt(
|
||||
Math.pow(pos.x - startPos.x, 2) + Math.pow(pos.y - startPos.y, 2)
|
||||
);
|
||||
ctx.beginPath();
|
||||
ctx.arc(startPos.x, startPos.y, radius, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
if (isDrawing) {
|
||||
setIsDrawing(false);
|
||||
saveToHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const imageData = canvas.toDataURL("image/png");
|
||||
onSave?.(imageData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleText = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const text = prompt("입력할 텍스트:");
|
||||
if (text) {
|
||||
ctx.font = `${lineWidth * 8}px sans-serif`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, 50, 50);
|
||||
saveToHistory();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>이미지 편집기</DialogTitle>
|
||||
<DialogDescription>
|
||||
품목 이미지를 그리거나 편집합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 도구 모음 */}
|
||||
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
|
||||
<Button
|
||||
variant={tool === "pen" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTool("pen")}
|
||||
>
|
||||
<Pen className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool === "line" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTool("line")}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool === "rect" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTool("rect")}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool === "circle" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTool("circle")}
|
||||
>
|
||||
<Circle className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool === "text" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTool("text");
|
||||
handleText();
|
||||
}}
|
||||
>
|
||||
<Type className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool === "eraser" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTool("eraser")}
|
||||
>
|
||||
<Eraser className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-border mx-2" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={undo}
|
||||
disabled={historyStep <= 0}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearCanvas}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-border mx-2" />
|
||||
|
||||
{/* 색상 팔레트 */}
|
||||
<div className="flex gap-1">
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`w-6 h-6 rounded border-2 ${
|
||||
color === c ? "border-primary" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setColor(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선 두께 조절 */}
|
||||
<div className="flex items-center gap-4 px-3">
|
||||
<Label className="text-sm whitespace-nowrap">선 두께: {lineWidth}px</Label>
|
||||
<Slider
|
||||
value={[lineWidth]}
|
||||
onValueChange={(value) => setLineWidth(value[0])}
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 캔버스 */}
|
||||
<div className="border rounded-lg overflow-hidden bg-white">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={700}
|
||||
height={400}
|
||||
className="cursor-crosshair"
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,836 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Search,
|
||||
Download,
|
||||
Building2,
|
||||
Award,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Target,
|
||||
Eye,
|
||||
Edit,
|
||||
Network,
|
||||
User
|
||||
} from "lucide-react";
|
||||
import { BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
export function HRManagement() {
|
||||
const [activeTab, setActiveTab] = useState("organization");
|
||||
const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false);
|
||||
|
||||
// 직원 데이터
|
||||
const employees = [
|
||||
{
|
||||
id: "EMP001",
|
||||
name: "김대표",
|
||||
position: "대표이사",
|
||||
department: "경영진",
|
||||
team: "-",
|
||||
joinDate: "2015-01-01",
|
||||
email: "ceo@company.com",
|
||||
phone: "010-1111-1111",
|
||||
address: "서울시 강남구",
|
||||
birthDate: "1975-03-15",
|
||||
education: "서울대 경영학 석사",
|
||||
annualLeave: 0,
|
||||
usedLeave: 0,
|
||||
salary: "비공개",
|
||||
performance: 95,
|
||||
status: "재직",
|
||||
kpi: [
|
||||
{ metric: "매출 달성률", target: 100, actual: 112, unit: "%" },
|
||||
{ metric: "영업이익률", target: 15, actual: 18, unit: "%" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "EMP002",
|
||||
name: "이생산",
|
||||
position: "과장",
|
||||
department: "생산부",
|
||||
team: "생산1팀",
|
||||
joinDate: "2018-03-15",
|
||||
email: "lee@company.com",
|
||||
phone: "010-2222-2222",
|
||||
address: "경기도 안산시",
|
||||
birthDate: "1985-07-20",
|
||||
education: "한양대 기계공학 학사",
|
||||
annualLeave: 15,
|
||||
usedLeave: 8,
|
||||
salary: "5,500,000",
|
||||
performance: 88,
|
||||
status: "재직",
|
||||
kpi: [
|
||||
{ metric: "생산 목표 달성률", target: 100, actual: 105, unit: "%" },
|
||||
{ metric: "불량률", target: 2, actual: 1.2, unit: "%" },
|
||||
{ metric: "납기준수율", target: 95, actual: 98, unit: "%" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "EMP003",
|
||||
name: "박품질",
|
||||
position: "대리",
|
||||
department: "품질부",
|
||||
team: "품질관리팀",
|
||||
joinDate: "2019-06-01",
|
||||
email: "park@company.com",
|
||||
phone: "010-3333-3333",
|
||||
address: "경기도 시흥시",
|
||||
birthDate: "1988-11-10",
|
||||
education: "인하대 산업공학 학사",
|
||||
annualLeave: 15,
|
||||
usedLeave: 5,
|
||||
salary: "4,800,000",
|
||||
performance: 92,
|
||||
status: "재직",
|
||||
kpi: [
|
||||
{ metric: "품질 합격률", target: 98, actual: 99.2, unit: "%" },
|
||||
{ metric: "검사 처리시간", target: 24, actual: 18, unit: "시간" },
|
||||
{ metric: "고객 클레임", target: 5, actual: 2, unit: "건" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "EMP004",
|
||||
name: "정설비",
|
||||
position: "사원",
|
||||
department: "설비부",
|
||||
team: "설비관리팀",
|
||||
joinDate: "2021-09-01",
|
||||
email: "jung@company.com",
|
||||
phone: "010-4444-4444",
|
||||
address: "서울시 금천구",
|
||||
birthDate: "1993-05-25",
|
||||
education: "서울과기대 전기공학 학사",
|
||||
annualLeave: 15,
|
||||
usedLeave: 3,
|
||||
salary: "3,800,000",
|
||||
performance: 85,
|
||||
status: "재직",
|
||||
kpi: [
|
||||
{ metric: "설비 가동률", target: 90, actual: 93, unit: "%" },
|
||||
{ metric: "고장 처리시간", target: 4, actual: 3, unit: "시간" },
|
||||
{ metric: "예방정비 수행률", target: 100, actual: 100, unit: "%" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "EMP005",
|
||||
name: "최자재",
|
||||
position: "주임",
|
||||
department: "자재부",
|
||||
team: "구매팀",
|
||||
joinDate: "2020-02-15",
|
||||
email: "choi@company.com",
|
||||
phone: "010-5555-5555",
|
||||
address: "경기도 광명시",
|
||||
birthDate: "1990-08-30",
|
||||
education: "중앙대 물류학 학사",
|
||||
annualLeave: 15,
|
||||
usedLeave: 10,
|
||||
salary: "4,200,000",
|
||||
performance: 90,
|
||||
status: "재직",
|
||||
kpi: [
|
||||
{ metric: "구매 절감률", target: 5, actual: 7, unit: "%" },
|
||||
{ metric: "납기 준수율", target: 95, actual: 96, unit: "%" },
|
||||
{ metric: "재고 회전율", target: 12, actual: 14, unit: "회" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 조직도 데이터
|
||||
const organizationData = {
|
||||
name: "김대표",
|
||||
position: "대표이사",
|
||||
children: [
|
||||
{
|
||||
name: "생산부",
|
||||
position: "부장",
|
||||
children: [
|
||||
{ name: "생산1팀", position: "이생산 (과장)", members: 8 },
|
||||
{ name: "생산2팀", position: "강생산 (과장)", members: 7 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "품질부",
|
||||
position: "부장",
|
||||
children: [
|
||||
{ name: "품질관리팀", position: "박품질 (대리)", members: 5 },
|
||||
{ name: "품질보증팀", position: "오품질 (대리)", members: 4 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "영업부",
|
||||
position: "부장",
|
||||
children: [
|
||||
{ name: "영업1팀", position: "김영업 (과장)", members: 6 },
|
||||
{ name: "영업2팀", position: "이영업 (과장)", members: 5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "지원부",
|
||||
position: "부장",
|
||||
children: [
|
||||
{ name: "인사팀", position: "박인사 (과장)", members: 3 },
|
||||
{ name: "총무팀", position: "최총무 (대리)", members: 4 },
|
||||
{ name: "회계팀", position: "정회계 (과장)", members: 4 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 승진 후보 데이터
|
||||
const promotionCandidates = [
|
||||
{
|
||||
id: "EMP002",
|
||||
name: "이생산",
|
||||
currentPosition: "과장",
|
||||
targetPosition: "차장",
|
||||
department: "생산부",
|
||||
yearsInPosition: 3.5,
|
||||
performance: 88,
|
||||
evaluationScore: 92,
|
||||
status: "후보",
|
||||
expectedDate: "2025-01-01"
|
||||
},
|
||||
{
|
||||
id: "EMP003",
|
||||
name: "박품질",
|
||||
currentPosition: "대리",
|
||||
targetPosition: "과장",
|
||||
department: "품질부",
|
||||
yearsInPosition: 2.8,
|
||||
performance: 92,
|
||||
evaluationScore: 90,
|
||||
status: "후보",
|
||||
expectedDate: "2025-01-01"
|
||||
},
|
||||
{
|
||||
id: "EMP005",
|
||||
name: "최자재",
|
||||
currentPosition: "주임",
|
||||
targetPosition: "대리",
|
||||
department: "자재부",
|
||||
yearsInPosition: 2.2,
|
||||
performance: 90,
|
||||
evaluationScore: 88,
|
||||
status: "검토중",
|
||||
expectedDate: "2025-03-01"
|
||||
}
|
||||
];
|
||||
|
||||
// 부서별 통계
|
||||
const departmentStats = [
|
||||
{ department: "생산부", count: 15, avgPerformance: 87 },
|
||||
{ department: "품질부", count: 9, avgPerformance: 90 },
|
||||
{ department: "영업부", count: 11, avgPerformance: 85 },
|
||||
{ department: "설비부", count: 6, avgPerformance: 86 },
|
||||
{ department: "자재부", count: 7, avgPerformance: 88 },
|
||||
{ department: "지원부", count: 11, avgPerformance: 84 }
|
||||
];
|
||||
|
||||
// 직급별 분포
|
||||
const positionDistribution = [
|
||||
{ position: "임원", count: 1 },
|
||||
{ position: "부장", count: 4 },
|
||||
{ position: "차장", count: 6 },
|
||||
{ position: "과장", count: 12 },
|
||||
{ position: "대리", count: 15 },
|
||||
{ position: "주임", count: 10 },
|
||||
{ position: "사원", count: 11 }
|
||||
];
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'];
|
||||
|
||||
const getPerformanceBadge = (score: number) => {
|
||||
if (score >= 90) return <Badge className="bg-green-500 text-white">우수</Badge>;
|
||||
if (score >= 80) return <Badge className="bg-blue-500 text-white">양호</Badge>;
|
||||
if (score >= 70) return <Badge className="bg-yellow-500 text-white">보통</Badge>;
|
||||
return <Badge className="bg-red-500 text-white">미흡</Badge>;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<string, string> = {
|
||||
"재직": "bg-green-500 text-white",
|
||||
"휴직": "bg-yellow-500 text-white",
|
||||
"퇴직": "bg-gray-500 text-white",
|
||||
};
|
||||
return <Badge className={statusConfig[status] || "bg-gray-500 text-white"}>{status}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">인사관리</h1>
|
||||
<p className="text-muted-foreground">조직, 인사, 평가, KPI 관리</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline" className="border-border/50">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
인사 데이터 다운로드
|
||||
</Button>
|
||||
<Dialog open={isAddEmployeeOpen} onOpenChange={setIsAddEmployeeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-primary text-primary-foreground">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
직원 등록
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>신규 직원 등록</DialogTitle>
|
||||
<DialogDescription>새로운 직원의 정보를 입력하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<h3 className="font-bold text-lg mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름 *</Label>
|
||||
<Input id="name" placeholder="홍길동" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employeeId">사번</Label>
|
||||
<Input id="employeeId" placeholder="EMP006" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서 *</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="생산부">생산부</SelectItem>
|
||||
<SelectItem value="품질부">품질부</SelectItem>
|
||||
<SelectItem value="영업부">영업부</SelectItem>
|
||||
<SelectItem value="설비부">설비부</SelectItem>
|
||||
<SelectItem value="자재부">자재부</SelectItem>
|
||||
<SelectItem value="지원부">지원부</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position">직급 *</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사원">사원</SelectItem>
|
||||
<SelectItem value="주임">주임</SelectItem>
|
||||
<SelectItem value="대리">대리</SelectItem>
|
||||
<SelectItem value="과장">과장</SelectItem>
|
||||
<SelectItem value="차장">차장</SelectItem>
|
||||
<SelectItem value="부장">부장</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="joinDate">입사일 *</Label>
|
||||
<Input id="joinDate" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="birthDate">생년월일</Label>
|
||||
<Input id="birthDate" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<h3 className="font-bold text-lg mb-4">연락처</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일 *</Label>
|
||||
<Input id="email" type="email" placeholder="name@company.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호 *</Label>
|
||||
<Input id="phone" placeholder="010-0000-0000" />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="address">주소</Label>
|
||||
<Input id="address" placeholder="서울시 강남구..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<h3 className="font-bold text-lg mb-4">학력 및 경력</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="education">최종학력</Label>
|
||||
<Input id="education" placeholder="예) 서울대 경영학 학사" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="career">경력사항</Label>
|
||||
<Textarea id="career" placeholder="이전 경력을 입력하세요" rows={3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddEmployeeOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-primary" onClick={() => setIsAddEmployeeOpen(false)}>
|
||||
등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">총 직원수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-foreground">59명</div>
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
<TrendingUp className="h-3 w-3 inline mr-1" />
|
||||
전월 대비 +3명
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">평균 근속연수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">4.2년</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">안정적 유지</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">평균 성과점수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">87점</div>
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
목표 대비 +7점
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">승진 예정자</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-purple-600">5명</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">2025년 1월</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">이직률</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-yellow-600">5.2%</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">업계 평균 이하</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 탭 메뉴 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 h-auto p-1 bg-muted/50 rounded-xl">
|
||||
<TabsTrigger value="organization" className="py-3 rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
<Network className="h-4 w-4 mr-2" />
|
||||
조직도
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="employees" className="py-3 rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
직원 관리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="leave" className="py-3 rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
연차 관리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="promotion" className="py-3 rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
<Award className="h-4 w-4 mr-2" />
|
||||
승진 관리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="kpi" className="py-3 rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
<Target className="h-4 w-4 mr-2" />
|
||||
KPI 관리
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 조직도 */}
|
||||
<TabsContent value="organization" className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 조직도 시각화 */}
|
||||
<Card className="border border-border/20 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>조직 구조</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* CEO */}
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-primary text-primary-foreground p-4 rounded-lg text-center w-48">
|
||||
<Building2 className="h-6 w-6 mx-auto mb-2" />
|
||||
<p className="font-bold">{organizationData.name}</p>
|
||||
<p className="text-sm">{organizationData.position}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{organizationData.children?.map((dept, idx) => (
|
||||
<div key={idx} className="space-y-3">
|
||||
<div className="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg text-center">
|
||||
<p className="font-bold text-sm">{dept.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{dept.position}</p>
|
||||
</div>
|
||||
{dept.children?.map((team, tidx) => (
|
||||
<div key={tidx} className="bg-muted/50 p-2 rounded text-center ml-2">
|
||||
<p className="text-sm font-medium">{team.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{team.position}</p>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{team.members}명
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 부서별 인원 통계 */}
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle>부서별 인원</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={departmentStats}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="department" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#3b82f6" name="인원" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 직급별 분포 */}
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle>직급별 분포</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={positionDistribution}
|
||||
dataKey="count"
|
||||
nameKey="position"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
label={(entry) => `${entry.position} (${entry.count})`}
|
||||
>
|
||||
{positionDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 직원 관리 */}
|
||||
<TabsContent value="employees" className="space-y-4">
|
||||
{/* 검색 및 필터 */}
|
||||
<Card className="border border-border/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="이름, 부서로 검색..." className="pl-10" />
|
||||
</div>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder="부서" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="생산부">생산부</SelectItem>
|
||||
<SelectItem value="품질부">품질부</SelectItem>
|
||||
<SelectItem value="영업부">영업부</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder="직급" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="부장">부장</SelectItem>
|
||||
<SelectItem value="과장">과장</SelectItem>
|
||||
<SelectItem value="대리">대리</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 직원 목록 */}
|
||||
<Card className="border border-border/20">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>사번</TableHead>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>직급</TableHead>
|
||||
<TableHead>입사일</TableHead>
|
||||
<TableHead>연락처</TableHead>
|
||||
<TableHead>성과</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map((emp) => (
|
||||
<TableRow key={emp.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-sm">{emp.id}</TableCell>
|
||||
<TableCell className="font-medium">{emp.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{emp.department}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{emp.position}</TableCell>
|
||||
<TableCell className="text-sm">{emp.joinDate}</TableCell>
|
||||
<TableCell className="text-sm">{emp.phone}</TableCell>
|
||||
<TableCell>{getPerformanceBadge(emp.performance)}</TableCell>
|
||||
<TableCell>{getStatusBadge(emp.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 연차 관리 */}
|
||||
<TabsContent value="leave" className="space-y-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle>직원별 연차 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>직급</TableHead>
|
||||
<TableHead className="text-right">총 연차</TableHead>
|
||||
<TableHead className="text-right">사용</TableHead>
|
||||
<TableHead className="text-right">잔여</TableHead>
|
||||
<TableHead>사용률</TableHead>
|
||||
<TableHead className="text-center">상세</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.filter(e => e.annualLeave > 0).map((emp) => {
|
||||
const remaining = emp.annualLeave - emp.usedLeave;
|
||||
const usageRate = (emp.usedLeave / emp.annualLeave) * 100;
|
||||
return (
|
||||
<TableRow key={emp.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium">{emp.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{emp.department}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{emp.position}</TableCell>
|
||||
<TableCell className="text-right font-bold">{emp.annualLeave}일</TableCell>
|
||||
<TableCell className="text-right text-blue-600">{emp.usedLeave}일</TableCell>
|
||||
<TableCell className="text-right text-green-600">{remaining}일</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Progress value={usageRate} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">{usageRate.toFixed(0)}%</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 승진 관리 */}
|
||||
<TabsContent value="promotion" className="space-y-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle>승진 후보자</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>현재 직급</TableHead>
|
||||
<TableHead>승진 예정 직급</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>현 직급 근속</TableHead>
|
||||
<TableHead>성과 점수</TableHead>
|
||||
<TableHead>평가 점수</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>예정일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{promotionCandidates.map((candidate) => (
|
||||
<TableRow key={candidate.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium">{candidate.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{candidate.currentPosition}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-purple-500 text-white">
|
||||
<Award className="h-3 w-3 mr-1" />
|
||||
{candidate.targetPosition}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{candidate.department}</TableCell>
|
||||
<TableCell>{candidate.yearsInPosition}년</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={candidate.performance} className="h-2 w-16" />
|
||||
<span className="text-sm">{candidate.performance}점</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={candidate.evaluationScore} className="h-2 w-16" />
|
||||
<span className="text-sm">{candidate.evaluationScore}점</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={candidate.status === "후보" ? "bg-green-500 text-white" : "bg-yellow-500 text-white"}>
|
||||
{candidate.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{candidate.expectedDate}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* KPI 관리 */}
|
||||
<TabsContent value="kpi" className="space-y-4">
|
||||
{employees.slice(0, 3).map((emp) => (
|
||||
<Card key={emp.id} className="border border-border/20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{emp.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{emp.department} · {emp.position}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">종합 성과</p>
|
||||
<p className="text-2xl font-bold text-primary">{emp.performance}점</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{emp.kpi.map((kpi, idx) => {
|
||||
const achievement = (kpi.actual / kpi.target) * 100;
|
||||
const isGood = kpi.metric.includes("불량률") || kpi.metric.includes("처리시간") || kpi.metric.includes("클레임")
|
||||
? kpi.actual <= kpi.target
|
||||
: kpi.actual >= kpi.target;
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">{kpi.metric}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-bold text-lg">
|
||||
{kpi.actual}{kpi.unit}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
/ 목표 {kpi.target}{kpi.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Progress value={Math.min(achievement, 100)} className="h-3" />
|
||||
<Badge className={isGood ? "bg-green-500 text-white" : "bg-red-500 text-white"}>
|
||||
{achievement.toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,527 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ContactModal } from "./ContactModal";
|
||||
import {
|
||||
Factory,
|
||||
CheckSquare,
|
||||
Package,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Award,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Sparkles,
|
||||
Target,
|
||||
Star,
|
||||
Cpu,
|
||||
Activity,
|
||||
Users,
|
||||
Zap,
|
||||
Settings,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
|
||||
export function LandingPage() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"client" | "sales">("client");
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleDemoRequest = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleNavigateToDashboard = () => {
|
||||
navigate("/dashboard");
|
||||
};
|
||||
|
||||
const handleNavigateToSalesDashboard = () => {
|
||||
navigate("/dashboard/sales-leads");
|
||||
};
|
||||
|
||||
// 플로팅 기능 카드 데이터
|
||||
const floatingFeatures = [
|
||||
{ icon: Factory, title: "생산 관리", subtitle: "실시간 현황", color: "bg-blue-500", position: "top-20 left-10" },
|
||||
{ icon: CheckSquare, title: "품질 관리", subtitle: "불량 추적", color: "bg-green-500", position: "top-40 right-20" },
|
||||
{ icon: Package, title: "자재 관리", subtitle: "재고 최적화", color: "bg-orange-500", position: "bottom-40 left-20" },
|
||||
{ icon: Cpu, title: "설비 관리", subtitle: "가동률 모니터링", color: "bg-purple-500", position: "top-60 left-1/4" },
|
||||
{ icon: BarChart3, title: "실시간 분석", subtitle: "데이터 기반 의사결정", color: "bg-indigo-500", position: "bottom-32 right-1/4" },
|
||||
{ icon: Users, title: "인사 관리", subtitle: "근태 및 급여", color: "bg-pink-500", position: "top-32 right-1/3" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 via-white to-gray-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="backdrop-blur-sm bg-white/80 border-b border-gray-200/50 sticky top-0 z-50">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button className="flex items-center space-x-3 hover:scale-105 transition-transform">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shadow-md relative overflow-hidden bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<div className="text-white font-bold text-lg">S</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-gray-900">SAM</h1>
|
||||
<p className="text-xs text-gray-500">Smart Automation Management</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center gap-2 bg-gray-100 rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("client")}
|
||||
className={`px-4 py-2 rounded-full text-sm font-semibold transition-all ${
|
||||
viewMode === "client"
|
||||
? "bg-white text-blue-600 shadow-md"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
클라이언트 화면
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("sales")}
|
||||
className={`px-4 py-2 rounded-full text-sm font-semibold transition-all ${
|
||||
viewMode === "sales"
|
||||
? "bg-white text-blue-600 shadow-md"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
영업사원 화면
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNavigateToDashboard}
|
||||
className="rounded-full bg-blue-600 hover:bg-blue-700 text-white shadow-lg px-6"
|
||||
>
|
||||
대시보드로 이동
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{viewMode === "client" ? (
|
||||
<Button
|
||||
onClick={handleDemoRequest}
|
||||
variant="outline"
|
||||
className="rounded-full border-2 border-blue-600 text-blue-600 hover:bg-blue-50 px-6"
|
||||
>
|
||||
데모 요청
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNavigateToSalesDashboard}
|
||||
className="rounded-full bg-purple-600 hover:bg-purple-700 text-white shadow-lg px-6"
|
||||
>
|
||||
리드 관리
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section with Infographic Style */}
|
||||
<section className="relative py-20 md:py-32 overflow-hidden">
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Wave patterns */}
|
||||
<svg className="absolute top-20 left-0 w-32 h-32 text-blue-200 opacity-50" viewBox="0 0 100 100">
|
||||
<path d="M0 50 Q 25 40, 50 50 T 100 50" stroke="currentColor" fill="none" strokeWidth="2"/>
|
||||
<path d="M0 60 Q 25 50, 50 60 T 100 60" stroke="currentColor" fill="none" strokeWidth="2"/>
|
||||
<path d="M0 70 Q 25 60, 50 70 T 100 70" stroke="currentColor" fill="none" strokeWidth="2"/>
|
||||
</svg>
|
||||
|
||||
<svg className="absolute bottom-20 right-0 w-32 h-32 text-purple-200 opacity-50" viewBox="0 0 100 100">
|
||||
<path d="M0 50 Q 25 40, 50 50 T 100 50" stroke="currentColor" fill="none" strokeWidth="2"/>
|
||||
<path d="M0 60 Q 25 50, 50 60 T 100 60" stroke="currentColor" fill="none" strokeWidth="2"/>
|
||||
<path d="M0 70 Q 25 60, 50 70 T 100 70" stroke="currentColor" fill="none" strokeWidth="2"/>
|
||||
</svg>
|
||||
|
||||
{/* Dot patterns */}
|
||||
<div className="absolute top-40 right-20 grid grid-cols-4 gap-2 opacity-20">
|
||||
{[...Array(16)].map((_, i) => (
|
||||
<div key={i} className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-60 left-32 grid grid-cols-4 gap-2 opacity-20">
|
||||
{[...Array(16)].map((_, i) => (
|
||||
<div key={i} className="w-2 h-2 rounded-full bg-purple-400"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-6 relative z-10">
|
||||
{/* Main Hero Content */}
|
||||
<div className="text-center max-w-4xl mx-auto mb-16">
|
||||
<Badge className="mb-6 bg-blue-100 text-blue-700 border-0 rounded-full px-4 py-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 inline mr-2" />
|
||||
SAM Pro
|
||||
</Badge>
|
||||
|
||||
<h1 className="mb-6 text-gray-900 leading-tight">
|
||||
<span className="block text-4xl md:text-6xl font-extrabold mb-3">
|
||||
The Ultimate Manufacturing
|
||||
</span>
|
||||
<span className="block text-4xl md:text-6xl font-extrabold">
|
||||
& Business Management Solution
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mb-12 text-gray-600 text-lg md:text-xl max-w-3xl mx-auto leading-relaxed">
|
||||
생산부터 품질, 자재, 설비까지 모든 제조 프로세스를 자동화하고 관리하세요.<br />
|
||||
SAM으로 스마트 팩토리를 구축하고 생산성을 극대화하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Infographic Section with Floating Cards */}
|
||||
<div className="relative max-w-6xl mx-auto h-[600px]">
|
||||
{/* Floating Feature Cards - Hidden on mobile, visible on desktop */}
|
||||
<div className="hidden lg:block">
|
||||
{/* Top Left - 생산 관리 */}
|
||||
<div
|
||||
className={`absolute top-12 left-8 ${isVisible ? 'animate-float' : 'opacity-0'}`}
|
||||
style={{ animationDelay: '0s' }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-4 flex items-center gap-3 hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center flex-shrink-0">
|
||||
<Factory className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">생산 관리</div>
|
||||
<div className="text-sm text-gray-500">실시간 현황</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right - 품질 관리 */}
|
||||
<div
|
||||
className={`absolute top-24 right-12 ${isVisible ? 'animate-float' : 'opacity-0'}`}
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-4 flex items-center gap-3 hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 rounded-xl bg-green-500 flex items-center justify-center flex-shrink-0">
|
||||
<CheckSquare className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">품질 관리</div>
|
||||
<div className="text-sm text-gray-500">불량 추적</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left Middle - 설비 관리 */}
|
||||
<div
|
||||
className={`absolute top-48 left-4 ${isVisible ? 'animate-float' : 'opacity-0'}`}
|
||||
style={{ animationDelay: '0.4s' }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-4 flex items-center gap-3 hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500 flex items-center justify-center flex-shrink-0">
|
||||
<Cpu className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">설비 관리</div>
|
||||
<div className="text-sm text-gray-500">가동률 모니터링</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Middle - 인사 관리 */}
|
||||
<div
|
||||
className={`absolute top-40 right-6 ${isVisible ? 'animate-float' : 'opacity-0'}`}
|
||||
style={{ animationDelay: '0.6s' }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-4 flex items-center gap-3 hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 rounded-xl bg-pink-500 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">인사 관리</div>
|
||||
<div className="text-sm text-gray-500">근태 및 급여</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Left - 자재 관리 */}
|
||||
<div
|
||||
className={`absolute bottom-20 left-16 ${isVisible ? 'animate-float' : 'opacity-0'}`}
|
||||
style={{ animationDelay: '0.8s' }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-4 flex items-center gap-3 hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-500 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">자재 관리</div>
|
||||
<div className="text-sm text-gray-500">재고 최적화</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Right - 실시간 분석 */}
|
||||
<div
|
||||
className={`absolute bottom-28 right-20 ${isVisible ? 'animate-float' : 'opacity-0'}`}
|
||||
style={{ animationDelay: '1s' }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg p-4 flex items-center gap-3 hover:shadow-xl transition-shadow">
|
||||
<div className="w-12 h-12 rounded-xl bg-indigo-500 flex items-center justify-center flex-shrink-0">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">실시간 분석</div>
|
||||
<div className="text-sm text-gray-500">데이터 인사이트</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar-like icons */}
|
||||
<div className={`absolute top-32 left-1/3 ${isVisible ? 'animate-float' : 'opacity-0'}`} style={{ animationDelay: '0.3s' }}>
|
||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 shadow-lg flex items-center justify-center">
|
||||
<Factory className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`absolute bottom-40 right-1/3 ${isVisible ? 'animate-float' : 'opacity-0'}`} style={{ animationDelay: '0.7s' }}>
|
||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-green-400 to-green-600 shadow-lg flex items-center justify-center">
|
||||
<Award className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Dashboard Mockup */}
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-3xl transition-all duration-1000 ${isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}`}>
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 rounded-3xl blur-2xl opacity-20"></div>
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl overflow-hidden border-2 border-gray-100">
|
||||
{/* Browser Chrome */}
|
||||
<div className="bg-gray-100 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-400"></div>
|
||||
</div>
|
||||
<div className="flex-1 mx-4 bg-white rounded-lg px-3 py-1 text-xs text-gray-500 text-center">
|
||||
https://sam-mes.com/dashboard
|
||||
</div>
|
||||
<div className="w-16"></div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Content */}
|
||||
<div className="p-6 bg-gradient-to-br from-gray-50 to-white">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-4 shadow-md">
|
||||
<div className="text-white/80 text-xs mb-1">생산 현황</div>
|
||||
<div className="text-white text-2xl font-bold">94.2%</div>
|
||||
<div className="text-white/70 text-xs mt-1">↑ 12.5%</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-xl p-4 shadow-md">
|
||||
<div className="text-white/80 text-xs mb-1">품질 지수</div>
|
||||
<div className="text-white text-2xl font-bold">98.5</div>
|
||||
<div className="text-white/70 text-xs mt-1">↑ 8.2%</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-4 shadow-md">
|
||||
<div className="text-white/80 text-xs mb-1">가동률</div>
|
||||
<div className="text-white text-2xl font-bold">87.3%</div>
|
||||
<div className="text-white/70 text-xs mt-1">↑ 15.1%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div className="h-32 relative">
|
||||
<div className="absolute inset-0 flex items-end justify-between space-x-0.5">
|
||||
{[65, 75, 60, 85, 70, 90, 75, 80, 85, 78, 88, 92, 95, 90, 88, 85, 92, 88].map((height, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gradient-to-t from-blue-500 to-purple-500 rounded-t hover:from-blue-600 hover:to-purple-600 transition-all"
|
||||
style={{ height: `${height}%` }}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why SAM Section */}
|
||||
<section className="relative py-16 bg-white">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="text-center mb-12 max-w-3xl mx-auto">
|
||||
<h2 className="mb-4 text-gray-900 text-3xl md:text-4xl font-extrabold">
|
||||
Why SAM <span className="text-blue-600">Pro</span>
|
||||
</h2>
|
||||
<p className="text-gray-600 text-lg">
|
||||
중소·중견기업의 제조 현장을 위해 설계된 통합 MES 솔루션으로<br />
|
||||
생산성을 극대화하고 디지털 전환을 가속화하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{[
|
||||
{
|
||||
icon: Zap,
|
||||
title: "빠른 도입",
|
||||
desc: "복잡한 설정 없이 3일 내 구축 가능",
|
||||
color: "bg-yellow-500"
|
||||
},
|
||||
{
|
||||
icon: Target,
|
||||
title: "맞춤형 솔루션",
|
||||
desc: "8개 산업 분야별 최적화된 프리셋",
|
||||
color: "bg-blue-500"
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
title: "검증된 성과",
|
||||
desc: "200+ 기업의 생산성 47% 향상",
|
||||
color: "bg-green-500"
|
||||
},
|
||||
].map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={i} className="text-center">
|
||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-2xl ${item.color} flex items-center justify-center shadow-lg`}>
|
||||
<Icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="mb-2 font-bold text-gray-900 text-xl">{item.title}</h3>
|
||||
<p className="text-gray-600">{item.desc}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="relative py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="mb-4 text-gray-900 text-3xl md:text-4xl font-extrabold">
|
||||
8개 모듈로 완성하는 스마트 팩토리
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
|
||||
{[
|
||||
{ icon: Factory, title: "생산관리", color: "from-blue-500 to-blue-600" },
|
||||
{ icon: CheckSquare, title: "품질관리", color: "from-green-500 to-green-600" },
|
||||
{ icon: Package, title: "자재관리", color: "from-orange-500 to-orange-600" },
|
||||
{ icon: Cpu, title: "설비관리", color: "from-purple-500 to-purple-600" },
|
||||
{ icon: BarChart3, title: "대시보드", color: "from-indigo-500 to-indigo-600" },
|
||||
{ icon: Shield, title: "시스템관리", color: "from-red-500 to-red-600" },
|
||||
{ icon: FileText, title: "기준정보", color: "from-teal-500 to-teal-600" },
|
||||
{ icon: TrendingUp, title: "보고서", color: "from-pink-500 to-pink-600" },
|
||||
].map((feature, i) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div key={i} className="bg-white rounded-xl p-6 text-center shadow-md hover:shadow-lg transition-shadow">
|
||||
<div className={`w-14 h-14 mx-auto mb-4 rounded-xl bg-gradient-to-br ${feature.color} flex items-center justify-center shadow-md`}>
|
||||
<Icon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-gray-900">{feature.title}</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="relative py-20 bg-gradient-to-br from-blue-600 via-blue-500 to-purple-600">
|
||||
<div className="container mx-auto px-6 text-center">
|
||||
<h2 className="mb-6 text-white text-3xl md:text-5xl font-extrabold">
|
||||
지금 바로 시작하세요
|
||||
</h2>
|
||||
<p className="text-white/90 text-lg mb-8">
|
||||
전문 영업사원이 1영업일 내에 연락드립니다
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleDemoRequest}
|
||||
className="rounded-full px-12 py-7 text-lg bg-white text-blue-600 hover:bg-gray-50 shadow-2xl hover:scale-105 transition-all font-bold"
|
||||
>
|
||||
<Sparkles className="mr-2 w-5 h-5" />
|
||||
무료 데모 신청
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200">
|
||||
<div className="container mx-auto px-6 py-12">
|
||||
<div className="grid md:grid-cols-4 gap-8 mb-8">
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shadow-md bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<div className="text-white font-bold text-lg">S</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">SAM</h3>
|
||||
<p className="text-xs text-gray-500">Smart Automation Management</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm max-w-md">
|
||||
중소·중견기업을 위한 스마트 MES 솔루션
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-3 text-gray-900">제품</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li><a href="#" className="hover:text-blue-600">기능 소개</a></li>
|
||||
<li><a href="#" onClick={(e) => { e.preventDefault(); handleDemoRequest(); }} className="hover:text-blue-600 cursor-pointer">데모 요청</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-3 text-gray-900">회사</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li><a href="#" className="hover:text-blue-600">회사 소개</a></li>
|
||||
<li><a href="#" className="hover:text-blue-600">문의하기</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6 text-center text-sm text-gray-500">
|
||||
<p>© 2025 SAM. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Contact Modal */}
|
||||
<ContactModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Float Animation */}
|
||||
<style>{`
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowRight,
|
||||
Building2
|
||||
} from "lucide-react";
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleLogin = () => {
|
||||
setError("");
|
||||
|
||||
// 간단한 데모 로그인 검증
|
||||
if (!email || !password) {
|
||||
setError("이메일과 비밀번호를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 데모 계정들
|
||||
const demoAccounts = [
|
||||
{ email: "ceo@demo.com", password: "demo1234", role: "CEO", name: "김대표" },
|
||||
{ email: "manager@demo.com", password: "demo1234", role: "ProductionManager", name: "이생산" },
|
||||
{ email: "worker@demo.com", password: "demo1234", role: "Worker", name: "박작업" },
|
||||
{ email: "admin@demo.com", password: "demo1234", role: "SystemAdmin", name: "최시스템" },
|
||||
{ email: "sales@demo.com", password: "demo1234", role: "Sales", name: "박영업" },
|
||||
];
|
||||
|
||||
const account = demoAccounts.find(acc => acc.email === email && acc.password === password);
|
||||
|
||||
if (account) {
|
||||
// Save user data to localStorage
|
||||
const userData = {
|
||||
email: account.email,
|
||||
role: account.role,
|
||||
name: account.name,
|
||||
companyName: "데모 기업",
|
||||
};
|
||||
localStorage.setItem("user", JSON.stringify(userData));
|
||||
|
||||
// Navigate to dashboard
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
setError("이메일 또는 비밀번호가 올바르지 않습니다");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="clean-glass border-b border-border">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="flex items-center space-x-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center clean-shadow relative overflow-hidden" style={{ backgroundColor: '#3B82F6' }}>
|
||||
<div className="text-white font-bold text-lg">S</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent opacity-30"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-wide">SAM</h1>
|
||||
<p className="text-xs text-muted-foreground">로그인</p>
|
||||
</div>
|
||||
</button>
|
||||
<Button variant="ghost" onClick={() => navigate("/signup")} className="rounded-xl">
|
||||
회원가입
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Login Card */}
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-foreground">로그인</h2>
|
||||
<p className="text-muted-foreground">SAM MES 시스템에 오신 것을 환영합니다</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-xl p-4">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email" className="flex items-center space-x-2 mb-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>이메일</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>비밀번호</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="clean-input pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">로그인 상태 유지</span>
|
||||
</label>
|
||||
<button className="text-sm text-primary hover:underline">
|
||||
비밀번호 찾기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
className="w-full rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
로그인
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-card text-muted-foreground">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="w-full rounded-xl"
|
||||
>
|
||||
새 계정 만들기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Info */}
|
||||
<div className="mt-6 clean-glass rounded-xl p-6 clean-shadow">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Building2 className="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-2">데모 계정 안내</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
바로 체험해보시려면 아래 계정으로 로그인하세요:
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="font-medium text-foreground">대표이사</p>
|
||||
<p className="text-muted-foreground">이메일: ceo@demo.com</p>
|
||||
<p className="text-muted-foreground">비밀번호: demo1234</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="font-medium text-foreground">생산관리자</p>
|
||||
<p className="text-muted-foreground">이메일: manager@demo.com</p>
|
||||
<p className="text-muted-foreground">비밀번호: demo1234</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="font-medium text-foreground">생산작업자</p>
|
||||
<p className="text-muted-foreground">이메일: worker@demo.com</p>
|
||||
<p className="text-muted-foreground">비밀번호: demo1234</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="font-medium text-foreground">시스템관리자</p>
|
||||
<p className="text-muted-foreground">이메일: admin@demo.com</p>
|
||||
<p className="text-muted-foreground">비밀번호: demo1234</p>
|
||||
</div>
|
||||
<div className="bg-primary/10 rounded-lg p-3 border border-primary/30">
|
||||
<p className="font-medium text-foreground flex items-center">
|
||||
영업사원 (리드 관리)
|
||||
<Badge className="ml-2 bg-primary text-white text-xs">NEW</Badge>
|
||||
</p>
|
||||
<p className="text-muted-foreground">이메일: sales@demo.com</p>
|
||||
<p className="text-muted-foreground">비밀번호: demo1234</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signup Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
아직 계정이 없으신가요?{" "}
|
||||
<button
|
||||
onClick={() => navigate("/signup")}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
30일 무료 체험 시작
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Archive,
|
||||
Search,
|
||||
Filter,
|
||||
BarChart3,
|
||||
Package,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
QrCode,
|
||||
MapPin
|
||||
} from "lucide-react";
|
||||
|
||||
interface Lot {
|
||||
id: string;
|
||||
lotNumber: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
manufactureDate: string;
|
||||
expiryDate: string;
|
||||
location: string;
|
||||
status: "정상" | "만료임박" | "만료";
|
||||
supplier: string;
|
||||
daysUntilExpiry: number;
|
||||
}
|
||||
|
||||
export function LotManagement() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState("all");
|
||||
const [selectedLocation, setSelectedLocation] = useState("all");
|
||||
|
||||
const lots: Lot[] = [
|
||||
{
|
||||
id: "1",
|
||||
lotNumber: "LOT-2024-A-001",
|
||||
itemCode: "RM-001",
|
||||
itemName: "스테인리스 강판 304",
|
||||
quantity: 850,
|
||||
unit: "kg",
|
||||
manufactureDate: "2024-08-15",
|
||||
expiryDate: "2025-08-15",
|
||||
location: "A동-1구역",
|
||||
status: "정상",
|
||||
supplier: "포스코",
|
||||
daysUntilExpiry: 304
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
lotNumber: "LOT-2024-B-015",
|
||||
itemCode: "RM-002",
|
||||
itemName: "구리 파이프",
|
||||
quantity: 120,
|
||||
unit: "m",
|
||||
manufactureDate: "2024-09-20",
|
||||
expiryDate: "2025-09-20",
|
||||
location: "A동-2구역",
|
||||
status: "정상",
|
||||
supplier: "LG화학",
|
||||
daysUntilExpiry: 340
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
lotNumber: "LOT-2023-C-042",
|
||||
itemCode: "RM-003",
|
||||
itemName: "플라스틱 원료 ABS",
|
||||
quantity: 450,
|
||||
unit: "kg",
|
||||
manufactureDate: "2023-11-10",
|
||||
expiryDate: "2024-11-10",
|
||||
location: "B동-1구역",
|
||||
status: "만료임박",
|
||||
supplier: "한화솔루션",
|
||||
daysUntilExpiry: 26
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
lotNumber: "LOT-2023-A-128",
|
||||
itemCode: "SM-001",
|
||||
itemName: "볼트 M6",
|
||||
quantity: 5000,
|
||||
unit: "개",
|
||||
manufactureDate: "2023-06-15",
|
||||
expiryDate: "2024-06-15",
|
||||
location: "C동-3구역",
|
||||
status: "만료",
|
||||
supplier: "삼성정밀",
|
||||
daysUntilExpiry: -122
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
lotNumber: "LOT-2024-D-008",
|
||||
itemCode: "WP-001",
|
||||
itemName: "중간 조립품 A",
|
||||
quantity: 45,
|
||||
unit: "개",
|
||||
manufactureDate: "2024-10-01",
|
||||
expiryDate: "2025-10-01",
|
||||
location: "D동-1구역",
|
||||
status: "정상",
|
||||
supplier: "자체생산",
|
||||
daysUntilExpiry: 351
|
||||
}
|
||||
];
|
||||
|
||||
const locations = ["all", "A동-1구역", "A동-2구역", "B동-1구역", "C동-3구역", "D동-1구역"];
|
||||
const statuses = ["all", "정상", "만료임박", "만료"];
|
||||
|
||||
const filteredLots = lots.filter(lot => {
|
||||
const matchesSearch = lot.lotNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
lot.itemName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
lot.itemCode.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = selectedStatus === "all" || lot.status === selectedStatus;
|
||||
const matchesLocation = selectedLocation === "all" || lot.location === selectedLocation;
|
||||
return matchesSearch && matchesStatus && matchesLocation;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: lots.length,
|
||||
normal: lots.filter(l => l.status === "정상").length,
|
||||
nearExpiry: lots.filter(l => l.status === "만료임박").length,
|
||||
expired: lots.filter(l => l.status === "만료").length
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<Archive className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
로트 관리
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">제조 로트 추적 및 유통기한 관리</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<QrCode className="h-4 w-4 mr-2" />
|
||||
QR 코드 생성
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
로트 분석
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">전체 로트</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-600">{stats.total}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">관리 중인 로트</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">정상</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.normal}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">유통기한 양호</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-yellow-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">만료 임박</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.nearExpiry}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">30일 이내 만료</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-red-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">만료</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-red-600">{stats.expired}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">긴급 처리 필요</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="로트번호, 품목명, 품목코드로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 상태</SelectItem>
|
||||
{statuses.filter(s => s !== "all").map(status => (
|
||||
<SelectItem key={status} value={status}>{status}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder="보관 위치" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 위치</SelectItem>
|
||||
{locations.filter(l => l !== "all").map(location => (
|
||||
<SelectItem key={location} value={location}>{location}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 로트 목록 테이블 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>로트 목록 ({filteredLots.length}건)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">로트 번호</TableHead>
|
||||
<TableHead>품목 코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
<TableHead>제조일자</TableHead>
|
||||
<TableHead>유통기한</TableHead>
|
||||
<TableHead className="text-right">잔여일수</TableHead>
|
||||
<TableHead>보관위치</TableHead>
|
||||
<TableHead>공급처</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLots.map((lot) => (
|
||||
<TableRow key={lot.id}>
|
||||
<TableCell className="font-mono text-sm font-medium">
|
||||
{lot.lotNumber}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{lot.itemCode}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{lot.itemName}</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{lot.quantity.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{lot.unit}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||
{lot.manufactureDate}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
{lot.expiryDate}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className={`font-medium ${
|
||||
lot.daysUntilExpiry < 0 ? 'text-red-600' :
|
||||
lot.daysUntilExpiry <= 30 ? 'text-yellow-600' :
|
||||
'text-green-600'
|
||||
}`}>
|
||||
{lot.daysUntilExpiry < 0 ?
|
||||
`만료 ${Math.abs(lot.daysUntilExpiry)}일` :
|
||||
`${lot.daysUntilExpiry}일`
|
||||
}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-muted-foreground" />
|
||||
{lot.location}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{lot.supplier}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={
|
||||
lot.status === "정상" ? "bg-green-500 text-white" :
|
||||
lot.status === "만료임박" ? "bg-yellow-500 text-white" :
|
||||
"bg-red-500 text-white"
|
||||
}>
|
||||
{lot.status === "정상" && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{lot.status === "만료임박" && <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||
{lot.status === "만료" && <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||
{lot.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 만료 임박 알림 */}
|
||||
{stats.nearExpiry > 0 && (
|
||||
<Card className="border-2 border-yellow-500 bg-yellow-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-900 mb-1">
|
||||
만료 임박 로트 {stats.nearExpiry}건
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-800">
|
||||
30일 이내 유통기한이 만료되는 로트가 있습니다. 재고 처리를 검토해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 만료 알림 */}
|
||||
{stats.expired > 0 && (
|
||||
<Card className="border-2 border-red-500 bg-red-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-bold text-red-900 mb-1">
|
||||
만료된 로트 {stats.expired}건
|
||||
</h3>
|
||||
<p className="text-sm text-red-800">
|
||||
유통기한이 만료된 로트가 있습니다. 즉시 폐기 처리가 필요합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Factory,
|
||||
Users,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
Truck,
|
||||
@@ -22,7 +19,6 @@ import {
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Zap,
|
||||
Shield,
|
||||
Activity,
|
||||
Banknote,
|
||||
CreditCard,
|
||||
@@ -51,7 +47,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Area, AreaChart } from "recharts";
|
||||
|
||||
export function CEODashboard() {
|
||||
/**
|
||||
* MainDashboard - 통합 대시보드
|
||||
*
|
||||
* 사용자 역할과 메뉴는 백엔드에서 관리하며,
|
||||
* 이 대시보드는 모든 역할에 대해 공통으로 사용됩니다.
|
||||
* 표시되는 데이터는 백엔드 API에서 사용자 권한에 따라 필터링됩니다.
|
||||
*/
|
||||
export function MainDashboard() {
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
const [calendarDate, setCalendarDate] = useState<Date | undefined>(new Date());
|
||||
@@ -1219,7 +1222,7 @@ export function CEODashboard() {
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
formatter={(value: any) => [`${value}M원`, '']}
|
||||
formatter={(value: number | string) => [`${value}M원`, '']}
|
||||
labelFormatter={(label) => `${label.split('-')[1]}월`}
|
||||
/>
|
||||
<Bar dataKey="target" fill="#94a3b8" name="목표" radius={[4, 4, 0, 0]} />
|
||||
@@ -1287,7 +1290,7 @@ export function CEODashboard() {
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
formatter={(value: any) => [`${value}M원`, '']}
|
||||
formatter={(value: number | string) => [`${value}M원`, '']}
|
||||
labelFormatter={(label) => `${label.split('-')[1]}월`}
|
||||
/>
|
||||
<Line
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings, Code, Sparkles, Eye, CheckCircle } from "lucide-react";
|
||||
|
||||
export default function MenuCustomizationGuide() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-purple-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500 flex items-center justify-center">
|
||||
<Code className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-2xl">메뉴 커스터마이징 가이드</CardTitle>
|
||||
<CardDescription>시스템 관리자 전용 기능</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg mb-3 flex items-center gap-2">
|
||||
<Eye className="w-5 h-5 text-purple-600" />
|
||||
접근 방법
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-lg">
|
||||
<Badge className="bg-purple-500 text-white">1</Badge>
|
||||
<div>
|
||||
<p className="font-semibold">역할 전환</p>
|
||||
<p className="text-sm text-gray-600">헤더 우측 상단의 "역할 선택" 드롭다운에서 <strong>"시스템관리자"</strong> 선택</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-lg">
|
||||
<Badge className="bg-purple-500 text-white">2</Badge>
|
||||
<div>
|
||||
<p className="font-semibold">시스템 설정 메뉴 클릭</p>
|
||||
<p className="text-sm text-gray-600">좌측 사이드바에 표시되는 <Settings className="w-4 h-4 inline text-purple-600" /> <strong>"시스템 설정"</strong> 메뉴 클릭</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-lg">
|
||||
<Badge className="bg-purple-500 text-white">3</Badge>
|
||||
<div>
|
||||
<p className="font-semibold">메뉴 탭 선택</p>
|
||||
<p className="text-sm text-gray-600">상단 탭에서 <Code className="w-4 h-4 inline text-purple-600" /> <strong>"메뉴"</strong> 탭 클릭</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-lg">
|
||||
<Badge className="bg-purple-500 text-white">4</Badge>
|
||||
<div>
|
||||
<p className="font-semibold">커스터마이징 시작!</p>
|
||||
<p className="text-sm text-gray-600">카테고리, 메뉴 항목, 역할별 할당을 자유롭게 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="font-bold text-lg mb-3 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-pink-600" />
|
||||
AI 자동 매칭 사용법
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-lg">
|
||||
<Badge className="bg-pink-500 text-white">1</Badge>
|
||||
<div>
|
||||
<p className="font-semibold">AI 자동 매칭 버튼 클릭</p>
|
||||
<p className="text-sm text-gray-600">우측 상단의 <Sparkles className="w-4 h-4 inline text-pink-600" /> "AI 자동 매칭" 버튼 클릭</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-lg">
|
||||
<Badge className="bg-pink-500 text-white">2</Badge>
|
||||
<div>
|
||||
<p className="font-semibold">산업군 선택</p>
|
||||
<p className="text-sm text-gray-600">8가지 산업군 중 회사에 맞는 산업 선택 (자동차, 식품, 전자, 의약품 등)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-lg">
|
||||
<Badge className="bg-pink-500 text-white">3</Badge>
|
||||
<div>
|
||||
<p className="font-semibold">프리셋 적용</p>
|
||||
<p className="text-sm text-gray-600">"프리셋 적용" 버튼 클릭하면 AI가 최적화된 메뉴 자동 설정!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="font-bold text-lg mb-3 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
주요 기능
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-white rounded-lg border-l-4 border-blue-500">
|
||||
<p className="font-semibold text-sm">카테고리 관리</p>
|
||||
<p className="text-xs text-gray-600 mt-1">새로운 카테고리 생성 및 삭제</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border-l-4 border-green-500">
|
||||
<p className="font-semibold text-sm">메뉴 항목 관리</p>
|
||||
<p className="text-xs text-gray-600 mt-1">메뉴 추가, 수정, 삭제</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border-l-4 border-purple-500">
|
||||
<p className="font-semibold text-sm">역할별 할당</p>
|
||||
<p className="text-xs text-gray-600 mt-1">각 역할에 맞는 메뉴 구성</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Download,
|
||||
Filter,
|
||||
ShoppingCart,
|
||||
Calendar,
|
||||
User,
|
||||
Package,
|
||||
Truck,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Edit,
|
||||
Clock,
|
||||
FileText,
|
||||
Building,
|
||||
Phone,
|
||||
Mail,
|
||||
Calculator,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
RefreshCw
|
||||
} from "lucide-react";
|
||||
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
|
||||
export function OrderManagement() {
|
||||
const [activeTab, setActiveTab] = useState("orders");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState("all");
|
||||
const [isAddOrderOpen, setIsAddOrderOpen] = useState(false);
|
||||
|
||||
// 주문 데이터
|
||||
const orders = [
|
||||
{
|
||||
id: "ORD001",
|
||||
orderNo: "ORD-2024-12-001",
|
||||
customerName: "삼성전자",
|
||||
customerCode: "CUS-001",
|
||||
productName: "자동 가이드 레일 시스템",
|
||||
productCode: "AGRS-2024",
|
||||
quantity: 50,
|
||||
unitPrice: 850000,
|
||||
totalAmount: 42500000,
|
||||
orderDate: "2024-12-25",
|
||||
deliveryDate: "2025-01-15",
|
||||
status: "진행중",
|
||||
progress: 65,
|
||||
manager: "한영업"
|
||||
},
|
||||
{
|
||||
id: "ORD002",
|
||||
orderNo: "ORD-2024-12-002",
|
||||
customerName: "LG전자",
|
||||
customerCode: "CUS-002",
|
||||
productName: "스마트 케이스 모듈",
|
||||
productCode: "SCM-2024",
|
||||
quantity: 100,
|
||||
unitPrice: 450000,
|
||||
totalAmount: 45000000,
|
||||
orderDate: "2024-12-28",
|
||||
deliveryDate: "2025-01-20",
|
||||
status: "접수",
|
||||
progress: 10,
|
||||
manager: "한영업"
|
||||
},
|
||||
{
|
||||
id: "ORD003",
|
||||
orderNo: "ORD-2024-12-003",
|
||||
customerName: "현대자동차",
|
||||
customerCode: "CUS-003",
|
||||
productName: "하단 마감재 어셈블리",
|
||||
productCode: "BFA-2024",
|
||||
quantity: 200,
|
||||
unitPrice: 250000,
|
||||
totalAmount: 50000000,
|
||||
orderDate: "2024-12-30",
|
||||
deliveryDate: "2025-02-01",
|
||||
status: "견적",
|
||||
progress: 5,
|
||||
manager: "김영업"
|
||||
}
|
||||
];
|
||||
|
||||
// 고객사 데이터
|
||||
const customers = [
|
||||
{
|
||||
id: "CUS-001",
|
||||
name: "삼성전자",
|
||||
code: "SEC",
|
||||
contact: "김구매",
|
||||
phone: "02-2255-0114",
|
||||
email: "purchase@samsung.com",
|
||||
address: "서울시 서초구 서초대로 74길 11",
|
||||
rating: "A+",
|
||||
totalOrders: 25,
|
||||
totalAmount: 850000000,
|
||||
lastOrderDate: "2024-12-25"
|
||||
},
|
||||
{
|
||||
id: "CUS-002",
|
||||
name: "LG전자",
|
||||
code: "LGE",
|
||||
contact: "이구매",
|
||||
phone: "02-3777-1114",
|
||||
email: "order@lge.co.kr",
|
||||
address: "서울시 영등포구 여의대로 128",
|
||||
rating: "A",
|
||||
totalOrders: 18,
|
||||
totalAmount: 650000000,
|
||||
lastOrderDate: "2024-12-28"
|
||||
},
|
||||
{
|
||||
id: "CUS-003",
|
||||
name: "현대자동차",
|
||||
code: "HMC",
|
||||
contact: "박구매",
|
||||
phone: "02-3464-1114",
|
||||
email: "procurement@hyundai.com",
|
||||
address: "서울시 서초구 헌릉로 12",
|
||||
rating: "A+",
|
||||
totalOrders: 32,
|
||||
totalAmount: 1200000000,
|
||||
lastOrderDate: "2024-12-30"
|
||||
}
|
||||
];
|
||||
|
||||
// 주문 통계
|
||||
const orderStats = {
|
||||
totalOrders: orders.length,
|
||||
totalAmount: orders.reduce((sum, order) => sum + order.totalAmount, 0),
|
||||
pendingOrders: orders.filter(order => order.status === "견적").length,
|
||||
processingOrders: orders.filter(order => order.status === "진행중").length,
|
||||
completedOrders: orders.filter(order => order.status === "완료").length
|
||||
};
|
||||
|
||||
// 월별 주문 추이 데이터
|
||||
const monthlyOrders = [
|
||||
{ month: "8월", orders: 15, amount: 125000000 },
|
||||
{ month: "9월", orders: 18, amount: 145000000 },
|
||||
{ month: "10월", orders: 22, amount: 180000000 },
|
||||
{ month: "11월", orders: 20, amount: 165000000 },
|
||||
{ month: "12월", orders: 25, amount: 195000000 }
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "견적": return "bg-gray-500 text-white";
|
||||
case "접수": return "bg-blue-500 text-white";
|
||||
case "진행중": return "bg-orange-500 text-white";
|
||||
case "완료": return "bg-green-500 text-white";
|
||||
case "취소": return "bg-red-500 text-white";
|
||||
default: return "bg-gray-500 text-white";
|
||||
}
|
||||
};
|
||||
|
||||
const getRatingColor = (rating: string) => {
|
||||
switch (rating) {
|
||||
case "A+": return "bg-green-500 text-white";
|
||||
case "A": return "bg-blue-500 text-white";
|
||||
case "B": return "bg-yellow-500 text-white";
|
||||
case "C": return "bg-orange-500 text-white";
|
||||
default: return "bg-gray-500 text-white";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter(order => {
|
||||
const matchesSearch = order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.orderNo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.productName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = selectedStatus === "all" || order.status === selectedStatus;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">주문 관리</h1>
|
||||
<p className="text-muted-foreground mt-1">고객 주문 접수 및 진행 상황 관리</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
견적서 작성
|
||||
</Button>
|
||||
<Dialog open={isAddOrderOpen} onOpenChange={setIsAddOrderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
주문 등록
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>신규 주문 등록</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customer">고객사</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="고객사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map(customer => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orderDate">주문일</Label>
|
||||
<Input id="orderDate" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="product">제품명</Label>
|
||||
<Input id="product" placeholder="제품명 입력" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">수량</Label>
|
||||
<Input id="quantity" type="number" placeholder="50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unitPrice">단가</Label>
|
||||
<Input id="unitPrice" type="number" placeholder="850000" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryDate">납기일</Label>
|
||||
<Input id="deliveryDate" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requirements">요구사항</Label>
|
||||
<Textarea id="requirements" placeholder="고객 요구사항 및 특이사항" />
|
||||
</div>
|
||||
<div className="flex space-x-2 pt-4">
|
||||
<Button className="flex-1" onClick={() => setIsAddOrderOpen(false)}>
|
||||
등록
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" onClick={() => setIsAddOrderOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 주문 현황 대시보드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">총 주문</p>
|
||||
<p className="text-2xl font-bold text-primary">{orderStats.totalOrders}</p>
|
||||
</div>
|
||||
<ShoppingCart className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">총 주문금액</p>
|
||||
<p className="text-2xl font-bold text-green-600">{orderStats.totalAmount.toLocaleString()}원</p>
|
||||
</div>
|
||||
<Calculator className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">진행 중</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{orderStats.processingOrders}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">견적 대기</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{orderStats.pendingOrders}</p>
|
||||
</div>
|
||||
<FileText className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-muted rounded-2xl p-1">
|
||||
<TabsTrigger value="orders" className="rounded-xl">
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
주문 현황
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="customers" className="rounded-xl">
|
||||
<Building className="w-4 h-4 mr-2" />
|
||||
고객사 관리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analysis" className="rounded-xl">
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
주문 분석
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="orders">
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-64">
|
||||
<Label htmlFor="search" className="text-sm font-medium">검색</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="고객사명, 주문번호, 제품명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Label className="text-sm font-medium">상태</Label>
|
||||
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="견적">견적</SelectItem>
|
||||
<SelectItem value="접수">접수</SelectItem>
|
||||
<SelectItem value="진행중">진행중</SelectItem>
|
||||
<SelectItem value="완료">완료</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Excel 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 주문 목록 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>주문 목록 ({filteredOrders.length}건)</span>
|
||||
<Button size="sm" variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>주문번호</TableHead>
|
||||
<TableHead>고객사</TableHead>
|
||||
<TableHead>제품명</TableHead>
|
||||
<TableHead>수량</TableHead>
|
||||
<TableHead>단가</TableHead>
|
||||
<TableHead>총금액</TableHead>
|
||||
<TableHead>주문일</TableHead>
|
||||
<TableHead>납기일</TableHead>
|
||||
<TableHead>진행률</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>담당자</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="font-mono text-sm">{order.orderNo}</TableCell>
|
||||
<TableCell className="font-medium">{order.customerName}</TableCell>
|
||||
<TableCell>{order.productName}</TableCell>
|
||||
<TableCell>{order.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>{order.unitPrice.toLocaleString()}원</TableCell>
|
||||
<TableCell className="font-medium">{order.totalAmount.toLocaleString()}원</TableCell>
|
||||
<TableCell>{order.orderDate}</TableCell>
|
||||
<TableCell>{order.deliveryDate}</TableCell>
|
||||
<TableCell>
|
||||
<div className="w-16">
|
||||
<Progress value={order.progress} className="h-2" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{order.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getStatusColor(order.status)}>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-sm">{order.manager}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
<FileText className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="customers">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>고객사 관리</span>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
고객사 등록
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{customers.map((customer) => (
|
||||
<div key={customer.id} className="p-6 border rounded-2xl bg-gray-50/50">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-lg">{customer.name}</h4>
|
||||
<p className="text-muted-foreground text-sm">{customer.code}</p>
|
||||
</div>
|
||||
<Badge className={getRatingColor(customer.rating)}>
|
||||
{customer.rating}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">{customer.contact}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Phone className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">{customer.phone}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">{customer.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">총 주문</p>
|
||||
<p className="font-medium">{customer.totalOrders}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">총 금액</p>
|
||||
<p className="font-medium">{customer.totalAmount.toLocaleString()}원</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
최근 주문: {customer.lastOrderDate}
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analysis">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 월별 주문 추이 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle>월별 주문 추이</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={monthlyOrders}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis dataKey="month" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
stroke="#1428A0"
|
||||
strokeWidth={3}
|
||||
name="주문 건수"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
stroke="#00D084"
|
||||
strokeWidth={3}
|
||||
name="주문 금액(원)"
|
||||
yAxisId="right"
|
||||
/>
|
||||
<Legend />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 고객사별 주문 현황 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle>고객사별 주문 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={customers.slice(0, 5)}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis dataKey="code" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="totalOrders" fill="#1428A0" name="주문 건수" />
|
||||
<Legend />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 주문 현황 요약 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle>주문 현황 요약</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center p-6 border rounded-2xl bg-blue-50">
|
||||
<div className="text-3xl font-bold text-blue-600 mb-2">{orderStats.pendingOrders}</div>
|
||||
<p className="text-blue-700 font-medium">견적 대기</p>
|
||||
<p className="text-sm text-blue-600 mt-1">신속 처리 필요</p>
|
||||
</div>
|
||||
<div className="text-center p-6 border rounded-2xl bg-orange-50">
|
||||
<div className="text-3xl font-bold text-orange-600 mb-2">{orderStats.processingOrders}</div>
|
||||
<p className="text-orange-700 font-medium">진행 중</p>
|
||||
<p className="text-sm text-orange-600 mt-1">생산 진행</p>
|
||||
</div>
|
||||
<div className="text-center p-6 border rounded-2xl bg-green-50">
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">{orderStats.completedOrders}</div>
|
||||
<p className="text-green-700 font-medium">완료</p>
|
||||
<p className="text-sm text-green-600 mt-1">배송 준비</p>
|
||||
</div>
|
||||
<div className="text-center p-6 border rounded-2xl bg-purple-50">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">
|
||||
{(orderStats.totalAmount / 1000000).toFixed(0)}M
|
||||
</div>
|
||||
<p className="text-purple-700 font-medium">총 주문액</p>
|
||||
<p className="text-sm text-purple-600 mt-1">이번 달 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,980 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Box,
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
ChevronRight,
|
||||
Settings2,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
lotNumber: string;
|
||||
description: string;
|
||||
status: "활성" | "비활성";
|
||||
category?: string;
|
||||
bomItems?: BOMItem[];
|
||||
}
|
||||
|
||||
interface BOMItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface AvailableItem {
|
||||
id: string;
|
||||
type: string;
|
||||
code: string;
|
||||
name: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
defaultQty: number;
|
||||
}
|
||||
|
||||
export function ProductManagement() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState("all");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [currentTab, setCurrentTab] = useState("basic");
|
||||
|
||||
// BOM 관리
|
||||
const [bomCategory, setBomCategory] = useState("BOM관리");
|
||||
const [isItemSelectOpen, setIsItemSelectOpen] = useState(false);
|
||||
const [currentBomGroup, setCurrentBomGroup] = useState<string | null>(null);
|
||||
const [bomItems, setBomItems] = useState<BOMItem[]>([]);
|
||||
const [itemSearchTerm, setItemSearchTerm] = useState("");
|
||||
|
||||
// 신규 제품
|
||||
const [newProduct, setNewProduct] = useState<Partial<Product>>({
|
||||
code: "",
|
||||
name: "",
|
||||
lotNumber: "",
|
||||
description: "",
|
||||
status: "활성"
|
||||
});
|
||||
|
||||
// 샘플 제품 데이터
|
||||
const [products, setProducts] = useState<Product[]>([
|
||||
{
|
||||
id: "1",
|
||||
code: "KS501",
|
||||
name: "스크린센터",
|
||||
lotNumber: "SS",
|
||||
description: "스크린센터",
|
||||
status: "활성"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
code: "KWK503",
|
||||
name: "대형 세터",
|
||||
lotNumber: "WS",
|
||||
description: "11700*8500 이상",
|
||||
status: "비활성"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
code: "KOTS01",
|
||||
name: "플랫 세터",
|
||||
lotNumber: "TS",
|
||||
description: "플랫 세터(+수지형)",
|
||||
status: "활성"
|
||||
}
|
||||
]);
|
||||
|
||||
// 사용 가능한 품목 목록
|
||||
const availableItems: AvailableItem[] = [
|
||||
{ id: "1", type: "자재", code: "K-BE-C-E-E0R1330", name: "몸판판", specification: "몸판판-KEU-EAT-'919*3000", unit: "SET", defaultQty: 1 },
|
||||
{ id: "2", type: "부품", code: "H-SC-S-X-KF02*700", name: "신액배션", specification: "", unit: "SET", defaultQty: 1 },
|
||||
{ id: "3", type: "자재", code: "K-BE-C-E-E0R2045", name: "몸판판", specification: "몸판판-KEU-I-SET-'125*425", unit: "SET", defaultQty: 1 },
|
||||
{ id: "4", type: "부품", code: "H-SC-S-X-KF02*C80122", name: "신액배션", specification: "신액배션스크린-U220", unit: "SET", defaultQty: 1 },
|
||||
{ id: "5", type: "자재", code: "P-ET-C-S-KWS01", name: "관산판", specification: "관산판 스크린(콘솔내판)", unit: "SET", defaultQty: 0 },
|
||||
{ id: "6", type: "부품", code: "K-ET-C-X-YT01", name: "국제판", specification: "국제판-스틸샷", unit: "SET", defaultQty: 0 },
|
||||
{ id: "7", type: "자재", code: "P-ET-S-X-YM01", name: "국제판", specification: "국제판-스틸샷", unit: "SET", defaultQty: 0 },
|
||||
{ id: "8", type: "부품", code: "P-ET-S-X-KGT5001", name: "관산판", specification: "관산판 스크린(콘솔내판)", unit: "SET", defaultQty: 0 },
|
||||
{ id: "9", type: "자재", code: "P-ET-S-X-KGT1902", name: "버튼판", specification: "버튼판 스크린(콘솔)", unit: "SET", defaultQty: 0 },
|
||||
{ id: "10", type: "부품", code: "P-ET-S-B-S450", name: "국제판", specification: "국제판 스크린(콘솔)", unit: "SET", defaultQty: 0 }
|
||||
];
|
||||
|
||||
// BOM 그룹 샘플 데이터
|
||||
const bomGroups = [
|
||||
{ id: "g1", name: "분류 ID: 3", items: 2 },
|
||||
{ id: "g2", name: "분류 ID: 4", items: 2 }
|
||||
];
|
||||
|
||||
const filteredProducts = products.filter(product => {
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
product.code.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = selectedStatus === "all" || product.status === selectedStatus;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const filteredItems = availableItems.filter(item => {
|
||||
return item.name.toLowerCase().includes(itemSearchTerm.toLowerCase()) ||
|
||||
item.code.toLowerCase().includes(itemSearchTerm.toLowerCase());
|
||||
});
|
||||
|
||||
const handleEdit = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
if (product.bomItems) {
|
||||
setBomItems(product.bomItems);
|
||||
} else {
|
||||
setBomItems([]);
|
||||
}
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (selectedProduct) {
|
||||
setProducts(products.filter(p => p.id !== selectedProduct.id));
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedProduct(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProduct = () => {
|
||||
const productToAdd: Product = {
|
||||
id: `${Date.now()}`,
|
||||
code: newProduct.code || "",
|
||||
name: newProduct.name || "",
|
||||
lotNumber: newProduct.lotNumber || "",
|
||||
description: newProduct.description || "",
|
||||
status: newProduct.status || "활성",
|
||||
bomItems: bomItems
|
||||
};
|
||||
|
||||
setProducts([...products, productToAdd]);
|
||||
setIsAddDialogOpen(false);
|
||||
setNewProduct({
|
||||
code: "",
|
||||
name: "",
|
||||
lotNumber: "",
|
||||
description: "",
|
||||
status: "활성"
|
||||
});
|
||||
setBomItems([]);
|
||||
};
|
||||
|
||||
const handleUpdateProduct = () => {
|
||||
if (selectedProduct) {
|
||||
setProducts(products.map(p =>
|
||||
p.id === selectedProduct.id
|
||||
? { ...selectedProduct, bomItems }
|
||||
: p
|
||||
));
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedProduct(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddBomItems = (selectedIds: string[]) => {
|
||||
const itemsToAdd = availableItems
|
||||
.filter(item => selectedIds.includes(item.id))
|
||||
.map(item => ({
|
||||
id: `bom-${Date.now()}-${item.id}`,
|
||||
itemCode: item.code,
|
||||
itemName: item.name,
|
||||
specification: item.specification,
|
||||
quantity: item.defaultQty,
|
||||
unit: item.unit
|
||||
}));
|
||||
|
||||
setBomItems([...bomItems, ...itemsToAdd]);
|
||||
setIsItemSelectOpen(false);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: products.length,
|
||||
active: products.filter(p => p.status === "활성").length,
|
||||
inactive: products.filter(p => p.status === "비활성").length
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Box className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
제품 관리
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">제품(BOM) 등록 및 관리</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
제품 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">전체 제품</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">{stats.total}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">등록된 제품 수</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">활성 제품</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.active}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">판매/생산 가능</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-gray-500">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">비활성 제품</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-600">{stats.inactive}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">단종/개발중</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="제품코드/명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 상태</SelectItem>
|
||||
<SelectItem value="활성">활성</SelectItem>
|
||||
<SelectItem value="비활성">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 제품 목록 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-32">제품코드</TableHead>
|
||||
<TableHead>제품명</TableHead>
|
||||
<TableHead className="w-24">로트도</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-24 text-center">상태</TableHead>
|
||||
<TableHead className="w-32 text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProducts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
등록된 제품이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredProducts.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="font-mono text-sm">{product.code}</TableCell>
|
||||
<TableCell className="font-medium">{product.name}</TableCell>
|
||||
<TableCell className="text-sm">{product.lotNumber}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{product.description}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
className={
|
||||
product.status === "활성"
|
||||
? "bg-green-500 text-white hover:bg-green-600"
|
||||
: "bg-gray-500 text-white hover:bg-gray-600"
|
||||
}
|
||||
>
|
||||
{product.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(product)}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
onClick={() => handleDelete(product)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 신규 제품 등록 다이얼로그 */}
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>제품(BOM) 수정</DialogTitle>
|
||||
<DialogDescription>
|
||||
제품 정보와 BOM(자재 명세서)을 등록합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* 왼쪽 사이드바 */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<div className="text-sm font-medium mb-2">카테고리</div>
|
||||
<Button
|
||||
variant={bomCategory === "BOM관리" ? "default" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => setBomCategory("BOM관리")}
|
||||
>
|
||||
BOM관리
|
||||
</Button>
|
||||
<Button
|
||||
variant={bomCategory === "품목" ? "default" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => setBomCategory("품목")}
|
||||
>
|
||||
품목
|
||||
</Button>
|
||||
<Button
|
||||
variant={bomCategory === "불량" ? "default" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => setBomCategory("불량")}
|
||||
>
|
||||
불량
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="col-span-10 space-y-6">
|
||||
{bomCategory === "BOM관리" && (
|
||||
<>
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>코드</Label>
|
||||
<Input
|
||||
value={newProduct.code}
|
||||
onChange={(e) => setNewProduct({...newProduct, code: e.target.value})}
|
||||
placeholder="PR-A-SS001"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>제품 명</Label>
|
||||
<Input
|
||||
value={newProduct.name}
|
||||
onChange={(e) => setNewProduct({...newProduct, name: e.target.value})}
|
||||
placeholder="강화판"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>제품분류</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="제품" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="product">제품</SelectItem>
|
||||
<SelectItem value="part">부품</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>단위</Label>
|
||||
<Input placeholder="ea" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>1차카테고리</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="1차카테고리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="metal">금속</SelectItem>
|
||||
<SelectItem value="plastic">플라스틱</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>2차카테고리</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="2차카테고리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="steel">철강</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>3차카테고리</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="3차카테고리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sheet">판재</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>규격 모델(코기)</Label>
|
||||
<Input
|
||||
value={newProduct.lotNumber}
|
||||
onChange={(e) => setNewProduct({...newProduct, lotNumber: e.target.value})}
|
||||
placeholder="EGI-1.1ST-1219*3000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={newProduct.description}
|
||||
onChange={(e) => setNewProduct({...newProduct, description: e.target.value})}
|
||||
placeholder="철 국 판"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 상태 플래그 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태 플래그</Label>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="sellable" />
|
||||
<Label htmlFor="sellable" className="cursor-pointer">판매 가능</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="purchasable" />
|
||||
<Label htmlFor="purchasable" className="cursor-pointer">구매 가능</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="producible" defaultChecked />
|
||||
<Label htmlFor="producible" className="cursor-pointer">생산 가능</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="active" defaultChecked />
|
||||
<Label htmlFor="active" className="cursor-pointer">활성</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 규격 정보 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>규격 정보</Label>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
규격 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>속성</TableHead>
|
||||
<TableHead>값</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
<TableHead className="w-20 text-center">적용</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="text-center">1</TableCell>
|
||||
<TableCell>두께</TableCell>
|
||||
<TableCell>1.5</TableCell>
|
||||
<TableCell>T</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox defaultChecked />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* BOM 품목 목록 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>분류 ID: 3</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsItemSelectOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
부품 선택
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
부품 선택
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="text-red-600">
|
||||
카테고리 선택
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>명칭</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-24">단위</TableHead>
|
||||
<TableHead className="w-24">수량</TableHead>
|
||||
<TableHead className="w-24">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
부품 선택 버튼을 클릭하여 BOM 품목을 추가하세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
bomItems.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>자재</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.itemCode}</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{item.specification}</TableCell>
|
||||
<TableCell>{item.unit}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) => {
|
||||
const updated = [...bomItems];
|
||||
updated[index].quantity = parseInt(e.target.value) || 0;
|
||||
setBomItems(updated);
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
setBomItems(bomItems.filter((_, i) => i !== index));
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bomCategory === "품목" && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
품목 관리 기능
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bomCategory === "불량" && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
불량 관리 기능
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleAddProduct}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수정 다이얼로그 (신규와 동일한 구조) */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>제품(BOM) 수정</DialogTitle>
|
||||
<DialogDescription>
|
||||
제품 정보와 BOM(자재 명세서)을 수정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedProduct && (
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* 왼쪽 사이드바 */}
|
||||
<div className="col-span-2 space-y-2">
|
||||
<div className="text-sm font-medium mb-2">카테고리</div>
|
||||
<Button
|
||||
variant={bomCategory === "BOM관리" ? "default" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => setBomCategory("BOM관리")}
|
||||
>
|
||||
BOM관리
|
||||
</Button>
|
||||
<Button
|
||||
variant={bomCategory === "품목" ? "default" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => setBomCategory("품목")}
|
||||
>
|
||||
품목
|
||||
</Button>
|
||||
<Button
|
||||
variant={bomCategory === "불량" ? "default" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => setBomCategory("불량")}
|
||||
>
|
||||
불량
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="col-span-10 space-y-6">
|
||||
{bomCategory === "BOM관리" && (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>코드</Label>
|
||||
<Input
|
||||
value={selectedProduct.code}
|
||||
onChange={(e) => setSelectedProduct({...selectedProduct, code: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>제품 명</Label>
|
||||
<Input
|
||||
value={selectedProduct.name}
|
||||
onChange={(e) => setSelectedProduct({...selectedProduct, name: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>제품분류</Label>
|
||||
<Select defaultValue="product">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="product">제품</SelectItem>
|
||||
<SelectItem value="part">부품</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>단위</Label>
|
||||
<Input defaultValue="ea" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>규격 모델(코기)</Label>
|
||||
<Input
|
||||
value={selectedProduct.lotNumber}
|
||||
onChange={(e) => setSelectedProduct({...selectedProduct, lotNumber: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={selectedProduct.description}
|
||||
onChange={(e) => setSelectedProduct({...selectedProduct, description: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* BOM 품목 목록 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>분류 ID: 3</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsItemSelectOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
부품 선택
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>명칭</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-24">단위</TableHead>
|
||||
<TableHead className="w-24">수량</TableHead>
|
||||
<TableHead className="w-24">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
부품 선택 버튼을 클릭하여 BOM 품목을 추가하세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
bomItems.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>자재</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.itemCode}</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{item.specification}</TableCell>
|
||||
<TableCell>{item.unit}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) => {
|
||||
const updated = [...bomItems];
|
||||
updated[index].quantity = parseInt(e.target.value) || 0;
|
||||
setBomItems(updated);
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
setBomItems(bomItems.filter((_, i) => i !== index));
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleUpdateProduct}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 부품 선택 다이얼로그 */}
|
||||
<Dialog open={isItemSelectOpen} onOpenChange={setIsItemSelectOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>부품 선택</DialogTitle>
|
||||
<DialogDescription>
|
||||
BOM에 추가할 부품을 선택하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="부품 검색..."
|
||||
value={itemSearchTerm}
|
||||
onChange={(e) => setItemSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
엑셀 불러오기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border max-h-96 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">선택</TableHead>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>명칭</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
<TableHead>기본값</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<Checkbox id={`item-${item.id}`} />
|
||||
</TableCell>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.code}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{item.specification}</TableCell>
|
||||
<TableCell>{item.unit}</TableCell>
|
||||
<TableCell>{item.defaultQty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 {filteredItems.length}개 항목 검색
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setIsItemSelectOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
// 선택된 항목들의 ID를 수집 (실제로는 체크박스 상태를 추적해야 함)
|
||||
const selectedIds = filteredItems.slice(0, 2).map(i => i.id);
|
||||
handleAddBomItems(selectedIds);
|
||||
}}
|
||||
>
|
||||
선택 추가
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>제품 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말로 이 제품을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
{selectedProduct && (
|
||||
<div className="mt-4 p-3 bg-muted rounded-md">
|
||||
<p className="font-medium">{selectedProduct.name}</p>
|
||||
<p className="text-sm text-muted-foreground">코드: {selectedProduct.code}</p>
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,266 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCurrentTime } from "@/hooks/useCurrentTime";
|
||||
import {
|
||||
Factory,
|
||||
Package,
|
||||
CheckCircle,
|
||||
Users,
|
||||
Truck,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
|
||||
export function ProductionManagerDashboard() {
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
const productionManagerData = useMemo(() => {
|
||||
return {
|
||||
production: {
|
||||
planned: 1500,
|
||||
actual: 1320,
|
||||
efficiency: 88
|
||||
},
|
||||
quality: {
|
||||
passed: 1280,
|
||||
failed: 40,
|
||||
defectRate: 3.0
|
||||
},
|
||||
materials: {
|
||||
consumed: 2400,
|
||||
remaining: 8600,
|
||||
critical: 3
|
||||
},
|
||||
equipment: {
|
||||
operating: 18,
|
||||
maintenance: 2,
|
||||
downtime: 4.5
|
||||
},
|
||||
delivery: {
|
||||
onTime: 45,
|
||||
delayed: 3,
|
||||
shipped: 48
|
||||
},
|
||||
workers: {
|
||||
shift1: 42,
|
||||
shift2: 38,
|
||||
shift3: 25
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4 md:space-y-6">
|
||||
{/* 생산관리자 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground">생산관리 대시보드</h1>
|
||||
<p className="text-muted-foreground mt-1">생산 현황 및 운영 지표 · {currentTime}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Factory className="h-4 w-4 mr-2" />
|
||||
생산계획
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" size="sm">
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
실적분석
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 생산 현황 KPI */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">생산 효율</CardTitle>
|
||||
<Factory className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{productionManagerData.production.efficiency}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
실제: {productionManagerData.production.actual} / 계획: {productionManagerData.production.planned}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">품질 합격률</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{Math.round((productionManagerData.quality.passed / (productionManagerData.quality.passed + productionManagerData.quality.failed)) * 100)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
불량률: {productionManagerData.quality.defectRate}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">설비 가동률</CardTitle>
|
||||
<Settings className="h-4 w-4 text-purple-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{Math.round((productionManagerData.equipment.operating / (productionManagerData.equipment.operating + productionManagerData.equipment.maintenance)) * 100)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
다운타임: {productionManagerData.equipment.downtime}시간
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">납기 준수율</CardTitle>
|
||||
<Truck className="h-4 w-4 text-orange-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{Math.round((productionManagerData.delivery.onTime / productionManagerData.delivery.shipped) * 100)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
지연: {productionManagerData.delivery.delayed}건
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 현장 운영 현황 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 자재 사용량 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>자재 사용 현황</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">일일 소모량</span>
|
||||
<span className="font-bold">{productionManagerData.materials.consumed.toLocaleString()}kg</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">잔여 재고</span>
|
||||
<span className="font-bold">{productionManagerData.materials.remaining.toLocaleString()}kg</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-red-600">재고 부족 품목</span>
|
||||
<Badge className="bg-red-500 text-white">{productionManagerData.materials.critical}개</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 교대별 인력 현황 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span>교대별 인력 현황</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-2 bg-blue-50 rounded">
|
||||
<span className="text-sm font-medium">1교대 (08:00-16:00)</span>
|
||||
<span className="font-bold">{productionManagerData.workers.shift1}명</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-muted/50 dark:bg-muted/20 rounded">
|
||||
<span className="text-sm font-medium">2교대 (16:00-24:00)</span>
|
||||
<span className="font-bold">{productionManagerData.workers.shift2}명</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-purple-50 rounded">
|
||||
<span className="text-sm font-medium">3교대 (24:00-08:00)</span>
|
||||
<span className="font-bold">{productionManagerData.workers.shift3}명</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 설비 및 차량 현황 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>설비 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>가동중</span>
|
||||
<Badge className="bg-green-500 text-white">{productionManagerData.equipment.operating}대</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>정비중</span>
|
||||
<Badge className="bg-orange-500 text-white">{productionManagerData.equipment.maintenance}대</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Truck className="h-5 w-5" />
|
||||
<span>차량 관리</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>운행중</span>
|
||||
<Badge className="bg-blue-500 text-white">3대</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>대기중</span>
|
||||
<Badge className="bg-muted text-muted-foreground">2대</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span>현장 알림</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span>납기 지연 위험 3건</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
||||
<span>설비 점검 필요 2대</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
|
||||
<span>자재 보충 필요 5개</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,370 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Trash2,
|
||||
Calculator,
|
||||
Download,
|
||||
Send,
|
||||
TrendingUp,
|
||||
Package,
|
||||
DollarSign,
|
||||
Percent,
|
||||
Save
|
||||
} from "lucide-react";
|
||||
|
||||
interface QuoteItem {
|
||||
id: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function QuoteSimulation() {
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerType, setCustomerType] = useState("new");
|
||||
const [quoteItems, setQuoteItems] = useState<QuoteItem[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [discount, setDiscount] = useState(0);
|
||||
|
||||
const products = [
|
||||
{ code: "PRO-2024-001", name: "스마트 센서 모듈 A", price: 150000 },
|
||||
{ code: "PRO-2024-002", name: "프리미엄 컨트롤러", price: 350000 },
|
||||
{ code: "PRO-2024-008", name: "산업용 모터 드라이버", price: 280000 }
|
||||
];
|
||||
|
||||
const addQuoteItem = () => {
|
||||
if (!selectedProduct) return;
|
||||
|
||||
const product = products.find(p => p.code === selectedProduct);
|
||||
if (!product) return;
|
||||
|
||||
const unitPrice = product.price;
|
||||
const discountAmount = unitPrice * (discount / 100);
|
||||
const finalUnitPrice = unitPrice - discountAmount;
|
||||
const total = finalUnitPrice * quantity;
|
||||
|
||||
const newItem: QuoteItem = {
|
||||
id: Date.now().toString(),
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice,
|
||||
discount: discount,
|
||||
total: total
|
||||
};
|
||||
|
||||
setQuoteItems([...quoteItems, newItem]);
|
||||
setSelectedProduct("");
|
||||
setQuantity(1);
|
||||
setDiscount(0);
|
||||
};
|
||||
|
||||
const removeQuoteItem = (id: string) => {
|
||||
setQuoteItems(quoteItems.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const subtotal = quoteItems.reduce((sum, item) => sum + item.total, 0);
|
||||
const vat = subtotal * 0.1;
|
||||
const total = subtotal + vat;
|
||||
|
||||
const avgDiscountRate = quoteItems.length > 0
|
||||
? quoteItems.reduce((sum, item) => sum + item.discount, 0) / quoteItems.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<FileText className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
모의 견적 하기
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">실시간 견적서 작성 및 시뮬레이션</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
임시저장
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
<Button className="bg-indigo-600 hover:bg-indigo-700">
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
견적서 발송
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 왼쪽: 견적 작성 폼 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* 고객 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">고객 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>고객사명 *</Label>
|
||||
<Input
|
||||
placeholder="고객사명을 입력하세요"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>고객 구분</Label>
|
||||
<Select value={customerType} onValueChange={setCustomerType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">신규 고객</SelectItem>
|
||||
<SelectItem value="regular">기존 고객 (일반)</SelectItem>
|
||||
<SelectItem value="vip">기존 고객 (VIP)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>담당자</Label>
|
||||
<Input placeholder="담당자명" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>연락처</Label>
|
||||
<Input placeholder="연락처" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 품목 추가 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">품목 추가</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-5 space-y-2">
|
||||
<Label>제품 선택</Label>
|
||||
<Select value={selectedProduct} onValueChange={setSelectedProduct}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="제품을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((product) => (
|
||||
<SelectItem key={product.code} value={product.code}>
|
||||
{product.name} (₩{product.price.toLocaleString()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>수량</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>할인율 (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 flex items-end">
|
||||
<Button
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700"
|
||||
onClick={addQuoteItem}
|
||||
disabled={!selectedProduct}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적 품목 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">견적 품목 ({quoteItems.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{quoteItems.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Package className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>품목을 추가해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{quoteItems.map((item) => (
|
||||
<div key={item.id} className="p-4 bg-muted/30 rounded-lg border">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{item.productName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.productCode}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
단가: ₩{item.unitPrice.toLocaleString()} |
|
||||
수량: {item.quantity}개 |
|
||||
할인: {item.discount}%
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeQuoteItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-3 border-t">
|
||||
<span className="text-sm text-muted-foreground">합계</span>
|
||||
<span className="text-lg font-bold text-indigo-600">
|
||||
₩{item.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 견적 요약 */}
|
||||
<div className="space-y-6">
|
||||
{/* 금액 요약 */}
|
||||
<Card className="border-2 border-indigo-200">
|
||||
<CardHeader className="bg-gradient-to-r from-indigo-50 to-purple-50">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5 text-indigo-600" />
|
||||
견적 금액
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-muted-foreground">품목 수</span>
|
||||
<span className="font-medium">{quoteItems.length}개</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-muted-foreground">총 수량</span>
|
||||
<span className="font-medium">
|
||||
{quoteItems.reduce((sum, item) => sum + item.quantity, 0)}개
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-muted-foreground">평균 할인율</span>
|
||||
<span className="font-medium text-orange-600">
|
||||
{avgDiscountRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-muted-foreground">소계</span>
|
||||
<span className="font-medium">₩{subtotal.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pb-3 border-b">
|
||||
<span className="text-muted-foreground">부가세 (10%)</span>
|
||||
<span className="font-medium">₩{vat.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<span className="text-lg font-bold">총 견적 금액</span>
|
||||
<span className="text-2xl font-bold text-indigo-600">
|
||||
₩{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">견적 통계</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm">평균 단가</span>
|
||||
</div>
|
||||
<span className="font-bold text-blue-600">
|
||||
₩{quoteItems.length > 0 ? Math.round(subtotal / quoteItems.reduce((sum, item) => sum + item.quantity, 0) || 0).toLocaleString() : 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Percent className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm">총 할인액</span>
|
||||
</div>
|
||||
<span className="font-bold text-purple-600">
|
||||
₩{Math.round(quoteItems.reduce((sum, item) => sum + (item.unitPrice * item.quantity * item.discount / 100), 0)).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm">예상 마진율</span>
|
||||
</div>
|
||||
<span className="font-bold text-green-600">
|
||||
35.2%
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 빠른 템플릿 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">빠른 템플릿</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
표준 견적서 (A)
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
대량 구매 견적서 (B)
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
VIP 고객 견적서 (C)
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Plus, X, BarChart, ArrowLeft } from "lucide-react";
|
||||
|
||||
// 입고등록 페이지
|
||||
interface ReceivingWritePageProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ReceivingWritePage({ onBack }: ReceivingWritePageProps) {
|
||||
const [selectedItem, setSelectedItem] = useState("EGIT-SST");
|
||||
const [spec, setSpec] = useState("1219*3500");
|
||||
const [receivingDate, setReceivingDate] = useState("2025-09-03");
|
||||
const [receivingUnit, setReceivingUnit] = useState("예");
|
||||
const [receivingQty, setReceivingQty] = useState("");
|
||||
const [inspectionEnabled, setInspectionEnabled] = useState(false);
|
||||
const [lotNumber, setLotNumber] = useState("250723-03");
|
||||
const [detailNumber, setDetailNumber] = useState("M-250723-0001");
|
||||
const [activeTab, setActiveTab] = useState<"origin" | "invoice">("origin");
|
||||
const [supplier, setSupplier] = useState("");
|
||||
const [manager, setManager] = useState("");
|
||||
const [manufacturer, setManufacturer] = useState("");
|
||||
|
||||
// 추가정보 동적 필드
|
||||
const [additionalFields, setAdditionalFields] = useState([
|
||||
{ id: 1, name: "출하(예: 제조, 중량, 길이 등)", value: "0" }
|
||||
]);
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
const newId = additionalFields.length > 0 ? Math.max(...additionalFields.map(f => f.id)) + 1 : 1;
|
||||
setAdditionalFields([...additionalFields, { id: newId, name: "", value: "" }]);
|
||||
};
|
||||
|
||||
// 필드 삭제
|
||||
const removeField = (id: number) => {
|
||||
setAdditionalFields(additionalFields.filter(f => f.id !== id));
|
||||
};
|
||||
|
||||
// 필드 업데이트
|
||||
const updateField = (id: number, key: "name" | "value", newValue: string) => {
|
||||
setAdditionalFields(additionalFields.map(f =>
|
||||
f.id === id ? { ...f, [key]: newValue } : f
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">입고 등록</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 md:p-6 space-y-6">
|
||||
{/* 1. 품목 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<Label className="text-lg font-semibold">품목</Label>
|
||||
</div>
|
||||
<Select value={selectedItem} onValueChange={setSelectedItem}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EGIT-SST">EGIT-SST - EGIT-SST</SelectItem>
|
||||
<SelectItem value="EGIT-SST2">EGIT-SST2 - EGIT-SST2</SelectItem>
|
||||
<SelectItem value="KSS-01">KSS-01 - KSS-01</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 2. 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<Label className="text-lg font-semibold">기본 정보</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 규격 */}
|
||||
<div className="space-y-2">
|
||||
<Label>규격</Label>
|
||||
<Input value={spec} onChange={(e) => setSpec(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* 품목 */}
|
||||
<div className="space-y-2">
|
||||
<Label>품목</Label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
E-빌1-SST
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
특-1219
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
길이-3500
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
제품:정기마감등도 공급받은 ID 3528, SECCO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 입고일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입고일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={receivingDate}
|
||||
onChange={(e) => setReceivingDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 입고단위 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입고단위</Label>
|
||||
<Select value={receivingUnit} onValueChange={setReceivingUnit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="예">예</SelectItem>
|
||||
<SelectItem value="kg">kg</SelectItem>
|
||||
<SelectItem value="m">m</SelectItem>
|
||||
<SelectItem value="EA">EA</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 입고량 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입고량</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="기본 수량"
|
||||
value={receivingQty}
|
||||
onChange={(e) => setReceivingQty(e.target.value)}
|
||||
className="flex-1"
|
||||
type="number"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setReceivingQty("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 추가정보 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<Label className="text-lg font-semibold">추가정보</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field.id} className="flex flex-col md:flex-row items-start md:items-center gap-2">
|
||||
<Input
|
||||
placeholder="출하(예: 제조, 중량, 길이 등)"
|
||||
value={field.name}
|
||||
onChange={(e) => updateField(field.id, "name", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => updateField(field.id, "value", e.target.value)}
|
||||
className="w-full md:w-32"
|
||||
type="number"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeField(field.id)}
|
||||
className="self-end md:self-auto"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={addField}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* 항목을 눌러 입고 추가할 필드명을 입력하실 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 납품업체, 담당자, 제조사 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>납품업체</Label>
|
||||
<Input
|
||||
placeholder="거주자스톡"
|
||||
value={supplier}
|
||||
onChange={(e) => setSupplier(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
placeholder="입고 담당"
|
||||
value={manager}
|
||||
onChange={(e) => setManager(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>제조사</Label>
|
||||
<Input
|
||||
placeholder="KG스틸 등"
|
||||
value={manufacturer}
|
||||
onChange={(e) => setManufacturer(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 검사여부 저장 */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
4
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={inspectionEnabled}
|
||||
onCheckedChange={setInspectionEnabled}
|
||||
/>
|
||||
<Label className="cursor-pointer">검사여부 저장</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. 상세내역 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
5
|
||||
</div>
|
||||
<Label className="text-lg font-semibold">상세내역 (로트 마법사 사용 시)</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 입고 로트번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입고 로트번호 (YYMMDD-XX)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">예:</span>
|
||||
<Input
|
||||
value={lotNumber}
|
||||
onChange={(e) => setLotNumber(e.target.value)}
|
||||
className="flex-1"
|
||||
placeholder="250723-03"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세내역 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상세내역 (로트 마법사 사용 시)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">예:</span>
|
||||
<Input
|
||||
value={detailNumber}
|
||||
onChange={(e) => setDetailNumber(e.target.value)}
|
||||
className="flex-1"
|
||||
placeholder="M-250723-0001"
|
||||
/>
|
||||
<Button variant="outline" size="icon">
|
||||
<BarChart className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 (원산지명, 거래명세표) */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-lg font-semibold">정보 (입시자명, 원산지 등)</Label>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "origin" | "invoice")}>
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsTrigger value="origin">원산지 입력</TabsTrigger>
|
||||
<TabsTrigger value="invoice">거래명세표 입력</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="origin" className="space-y-2 mt-4">
|
||||
<Textarea
|
||||
placeholder="원산지 정보를 입력하세요"
|
||||
rows={4}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invoice" className="space-y-2 mt-4">
|
||||
<Textarea
|
||||
placeholder="거래명세표 정보를 입력하세요"
|
||||
rows={4}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 6. 하단 버튼 */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-end gap-3 pt-4 border-t">
|
||||
<div className="bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold md:mr-2">
|
||||
6
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<Button variant="outline" onClick={onBack} className="flex-1 md:flex-none">
|
||||
조기홈
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 flex-1 md:flex-none">
|
||||
입고완료
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from "recharts";
|
||||
import { Download, Calendar, Filter, TrendingUp, TrendingDown, Minus, FileText, BarChart3, PieChart as PieChartIcon, Activity } from "lucide-react";
|
||||
|
||||
export function Reports() {
|
||||
const [selectedDateRange, setSelectedDateRange] = useState("month");
|
||||
const [selectedReport, setSelectedReport] = useState("production");
|
||||
|
||||
// 생산 실적 데이터
|
||||
const productionData = [
|
||||
{ date: "2025-09-01", planned: 1200, actual: 1150, efficiency: 95.8 },
|
||||
{ date: "2025-09-02", planned: 1300, actual: 1280, efficiency: 98.5 },
|
||||
{ date: "2025-09-03", planned: 1100, actual: 1050, efficiency: 95.5 },
|
||||
{ date: "2025-09-04", planned: 1400, actual: 1420, efficiency: 101.4 },
|
||||
{ date: "2025-09-05", planned: 1250, actual: 1200, efficiency: 96.0 },
|
||||
{ date: "2025-09-06", planned: 1350, actual: 1300, efficiency: 96.3 },
|
||||
{ date: "2025-09-07", planned: 1200, actual: 1180, efficiency: 98.3 },
|
||||
];
|
||||
|
||||
// 품질 데이터
|
||||
const qualityData = [
|
||||
{ product: "스마트폰 케이스", passRate: 98.5, defectRate: 1.5, totalInspected: 2500 },
|
||||
{ product: "태블릿 스탠드", passRate: 97.2, defectRate: 2.8, totalInspected: 1800 },
|
||||
{ product: "무선 충전기", passRate: 99.1, defectRate: 0.9, totalInspected: 3200 },
|
||||
{ product: "이어폰 케이스", passRate: 96.8, defectRate: 3.2, totalInspected: 2100 },
|
||||
];
|
||||
|
||||
// 자재 현황 데이터
|
||||
const materialData = [
|
||||
{ material: "플라스틱 원료", stock: 1250, minStock: 500, value: 3125000, turnover: 12.5 },
|
||||
{ material: "알루미늄 판재", stock: 85, minStock: 100, value: 1275000, turnover: 8.2 },
|
||||
{ material: "실리콘 패드", stock: 3200, minStock: 1000, value: 1600000, turnover: 15.8 },
|
||||
{ material: "전자부품 모듈", stock: 75, minStock: 100, value: 1875000, turnover: 6.5 },
|
||||
];
|
||||
|
||||
// 설비 가동률 데이터
|
||||
const equipmentData = [
|
||||
{ equipment: "CNC 머시닝센터 1호", uptime: 94.2, downtime: 5.8, productivity: 98.5 },
|
||||
{ equipment: "사출성형기 A라인", uptime: 89.1, downtime: 10.9, productivity: 92.3 },
|
||||
{ equipment: "자동포장기 1호", uptime: 96.8, downtime: 3.2, productivity: 99.1 },
|
||||
{ equipment: "품질검사기 QC-01", uptime: 98.5, downtime: 1.5, productivity: 97.8 },
|
||||
];
|
||||
|
||||
// 월별 매출 데이터
|
||||
const salesData = [
|
||||
{ month: "1월", sales: 85000000, cost: 62000000, profit: 23000000 },
|
||||
{ month: "2월", sales: 92000000, cost: 68000000, profit: 24000000 },
|
||||
{ month: "3월", sales: 78000000, cost: 59000000, profit: 19000000 },
|
||||
{ month: "4월", sales: 105000000, cost: 75000000, profit: 30000000 },
|
||||
{ month: "5월", sales: 98000000, cost: 72000000, profit: 26000000 },
|
||||
{ month: "6월", sales: 112000000, cost: 79000000, profit: 33000000 },
|
||||
{ month: "7월", sales: 108000000, cost: 77000000, profit: 31000000 },
|
||||
{ month: "8월", sales: 95000000, cost: 70000000, profit: 25000000 },
|
||||
{ month: "9월", sales: 118000000, cost: 82000000, profit: 36000000 },
|
||||
];
|
||||
|
||||
const COLORS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'];
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return `${(value / 1000000).toFixed(0)}M`;
|
||||
};
|
||||
|
||||
const getStatusIcon = (current: number, previous: number) => {
|
||||
if (current > previous) return <TrendingUp className="h-4 w-4 text-green-500" />;
|
||||
if (current < previous) return <TrendingDown className="h-4 w-4 text-red-500" />;
|
||||
return <Minus className="h-4 w-4 text-gray-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4 md:space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">보고서 및 분석</h1>
|
||||
<p className="text-gray-600 mt-1">생산, 품질, 자재, 설비, 매출 현황 분석</p>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<Select value={selectedDateRange} onValueChange={setSelectedDateRange}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="week">최근 1주</SelectItem>
|
||||
<SelectItem value="month">최근 1개월</SelectItem>
|
||||
<SelectItem value="quarter">최근 3개월</SelectItem>
|
||||
<SelectItem value="year">최근 1년</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" className="w-full md:w-auto">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
보고서 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핵심 지표 요약 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">월 생산량</CardTitle>
|
||||
<Activity className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">8,950개</div>
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
{getStatusIcon(8950, 8200)}
|
||||
<span>+9.1% 전월 대비</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">품질 수준</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">97.9%</div>
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
{getStatusIcon(97.9, 96.8)}
|
||||
<span>+1.1% 전월 대비</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">설비 가동률</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">94.7%</div>
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
{getStatusIcon(94.7, 92.3)}
|
||||
<span>+2.4% 전월 대비</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">월 매출</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">118M</div>
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
{getStatusIcon(118, 95)}
|
||||
<span>+24.2% 전월 대비</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="production" className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="grid w-full grid-cols-5 min-w-[500px]">
|
||||
<TabsTrigger value="production" className="flex items-center space-x-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">생산실적</span>
|
||||
<span className="sm:hidden">생산</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="quality" className="flex items-center space-x-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">품질분석</span>
|
||||
<span className="sm:hidden">품질</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="material" className="flex items-center space-x-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">자재현황</span>
|
||||
<span className="sm:hidden">자재</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="equipment" className="flex items-center space-x-2">
|
||||
<PieChartIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">설비분석</span>
|
||||
<span className="sm:hidden">설비</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sales" className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">매출분석</span>
|
||||
<span className="sm:hidden">매출</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="production" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>일별 생산 실적</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={productionData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="planned" fill="#94a3b8" name="계획" />
|
||||
<Bar dataKey="actual" fill="#3b82f6" name="실적" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>생산 효율성 추이</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={productionData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis domain={[90, 105]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="efficiency" stroke="#10b981" strokeWidth={3} name="효율성 (%)" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>제품별 생산 실적 상세</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{qualityData.map((item, index) => (
|
||||
<div key={index} className="p-4 border rounded-lg">
|
||||
<h4 className="font-medium mb-2">{item.product}</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>검사수량:</span>
|
||||
<span className="font-medium">{item.totalInspected.toLocaleString()}개</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>합격률:</span>
|
||||
<span className="text-green-600 font-medium">{item.passRate}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>불량률:</span>
|
||||
<span className="text-red-600 font-medium">{item.defectRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quality" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>제품별 품질 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={qualityData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="product" />
|
||||
<YAxis domain={[90, 100]} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="passRate" fill="#10b981" name="합격률 (%)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>불량률 분석</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={qualityData.map(item => ({ name: item.product, value: item.defectRate }))}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{qualityData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-4 space-y-2">
|
||||
{qualityData.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
></div>
|
||||
<span className="text-sm">{item.product}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{item.defectRate}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="material" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자재 재고 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={materialData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="material" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="stock" fill="#3b82f6" name="현재고" />
|
||||
<Bar dataKey="minStock" fill="#ef4444" name="최소재고" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자재 회전율</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={materialData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="material" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="turnover" stroke="#10b981" strokeWidth={3} name="회전율" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자재별 재고 가치 및 회전율</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">자재명</th>
|
||||
<th className="text-right p-2">현재고</th>
|
||||
<th className="text-right p-2">재고가치</th>
|
||||
<th className="text-right p-2">회전율</th>
|
||||
<th className="text-center p-2">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materialData.map((item, index) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2 font-medium">{item.material}</td>
|
||||
<td className="text-right p-2">{item.stock.toLocaleString()}</td>
|
||||
<td className="text-right p-2">{item.value.toLocaleString()}원</td>
|
||||
<td className="text-right p-2">{item.turnover}</td>
|
||||
<td className="text-center p-2">
|
||||
{item.stock < item.minStock ? (
|
||||
<span className="text-red-600 text-sm">부족</span>
|
||||
) : (
|
||||
<span className="text-green-600 text-sm">정상</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="equipment" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>설비 가동률</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={equipmentData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="equipment" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="uptime" fill="#10b981" name="가동률 (%)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>설비 생산성 지수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={equipmentData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="equipment" />
|
||||
<YAxis domain={[85, 100]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="productivity" stroke="#3b82f6" strokeWidth={3} name="생산성 (%)" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sales" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>월별 매출 추이</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={salesData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis tickFormatter={formatCurrency} />
|
||||
<Tooltip formatter={(value) => `${(value as number).toLocaleString()}원`} />
|
||||
<Area type="monotone" dataKey="sales" stackId="1" stroke="#3b82f6" fill="#3b82f6" name="매출" />
|
||||
<Area type="monotone" dataKey="cost" stackId="1" stroke="#ef4444" fill="#ef4444" name="비용" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>월별 수익성</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={salesData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis tickFormatter={formatCurrency} />
|
||||
<Tooltip formatter={(value) => `${(value as number).toLocaleString()}원`} />
|
||||
<Bar dataKey="profit" fill="#10b981" name="순이익" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>매출 성과 요약</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">863M원</div>
|
||||
<p className="text-sm text-gray-600">연누적 매출</p>
|
||||
<div className="flex items-center justify-center mt-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||
<span className="text-sm text-green-600">+15.2% YoY</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">237M원</div>
|
||||
<p className="text-sm text-gray-600">연누적 이익</p>
|
||||
<div className="flex items-center justify-center mt-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||
<span className="text-sm text-green-600">+22.8% YoY</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">27.5%</div>
|
||||
<p className="text-sm text-gray-600">이익률</p>
|
||||
<div className="flex items-center justify-center mt-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||
<span className="text-sm text-green-600">+2.1%p</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import {
|
||||
ArrowLeft,
|
||||
User,
|
||||
Building2,
|
||||
Mail,
|
||||
Phone,
|
||||
Briefcase,
|
||||
MessageSquare,
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Send,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
industry: string;
|
||||
message: string;
|
||||
status: "pending" | "contacted" | "demo-sent";
|
||||
submittedAt: string;
|
||||
demoLink?: string;
|
||||
demoExpiryDate?: string;
|
||||
industryPreset?: string;
|
||||
demoDuration?: number;
|
||||
}
|
||||
|
||||
interface SalesLeadDashboardProps {
|
||||
onStartDemo?: (config: any) => void;
|
||||
}
|
||||
|
||||
interface DemoConfig {
|
||||
demoId: string;
|
||||
leadId: string;
|
||||
industryPreset: string;
|
||||
demoDuration: number;
|
||||
expiryDate: string;
|
||||
createdAt: string;
|
||||
clientEmail: string;
|
||||
clientName: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
export function SalesLeadDashboard({ onStartDemo }: SalesLeadDashboardProps) {
|
||||
const navigate = useNavigate();
|
||||
const [leads, setLeads] = useState<Lead[]>([]);
|
||||
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
|
||||
const [isCreateDemoOpen, setIsCreateDemoOpen] = useState(false);
|
||||
|
||||
// Demo 생성 폼 데이터
|
||||
const [industryPreset, setIndustryPreset] = useState("");
|
||||
const [demoDuration, setDemoDuration] = useState("7");
|
||||
const [generatedLink, setGeneratedLink] = useState("");
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadLeads();
|
||||
}, []);
|
||||
|
||||
const loadLeads = () => {
|
||||
const storedLeads = JSON.parse(localStorage.getItem("salesLeads") || "[]");
|
||||
setLeads(storedLeads);
|
||||
};
|
||||
|
||||
const handleCreateDemo = (lead: Lead) => {
|
||||
setSelectedLead(lead);
|
||||
setIndustryPreset(lead.industry || "");
|
||||
setIsCreateDemoOpen(true);
|
||||
setGeneratedLink("");
|
||||
setIsCopied(false);
|
||||
};
|
||||
|
||||
const handleGenerateLink = () => {
|
||||
if (!selectedLead) return;
|
||||
|
||||
const demoId = `demo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setDate(expiryDate.getDate() + parseInt(demoDuration));
|
||||
|
||||
const demoLink = `${window.location.origin}/#/demo/${demoId}`;
|
||||
|
||||
// Demo 설정 저장
|
||||
const demoConfig = {
|
||||
demoId,
|
||||
leadId: selectedLead.id,
|
||||
industryPreset,
|
||||
demoDuration: parseInt(demoDuration),
|
||||
expiryDate: expiryDate.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
clientEmail: selectedLead.email,
|
||||
clientName: selectedLead.name,
|
||||
company: selectedLead.company,
|
||||
};
|
||||
|
||||
// localStorage에 demo 설정 저장
|
||||
const existingDemos = JSON.parse(localStorage.getItem("demoConfigs") || "{}");
|
||||
existingDemos[demoId] = demoConfig;
|
||||
localStorage.setItem("demoConfigs", JSON.stringify(existingDemos));
|
||||
|
||||
// Lead 상태 업데이트
|
||||
const updatedLeads = leads.map(l =>
|
||||
l.id === selectedLead.id
|
||||
? {
|
||||
...l,
|
||||
status: "demo-sent" as const,
|
||||
demoLink,
|
||||
demoExpiryDate: expiryDate.toISOString(),
|
||||
industryPreset,
|
||||
demoDuration: parseInt(demoDuration)
|
||||
}
|
||||
: l
|
||||
);
|
||||
setLeads(updatedLeads);
|
||||
localStorage.setItem("salesLeads", JSON.stringify(updatedLeads));
|
||||
|
||||
setGeneratedLink(demoLink);
|
||||
|
||||
toast.success("데모 링크가 생성되었습니다!", {
|
||||
description: `${selectedLead.email}로 이메일을 전송할 수 있습니다.`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (!generatedLink) return;
|
||||
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(generatedLink).then(() => {
|
||||
setIsCopied(true);
|
||||
toast.success("링크가 클립보드에 복사되었습니다!");
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
}).catch(() => {
|
||||
// Fallback to legacy method
|
||||
fallbackCopyTextToClipboard(generatedLink);
|
||||
});
|
||||
} else {
|
||||
// Use fallback for non-secure contexts
|
||||
fallbackCopyTextToClipboard(generatedLink);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
setIsCopied(true);
|
||||
toast.success("링크가 클립보드에 복사되었습니다!");
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} else {
|
||||
toast.error("복사에 실패했습니다. 링크를 수동으로 복사해주세요.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("복사에 실패했습니다. 링크를 수동으로 복사해주세요.");
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
const handleSendEmail = () => {
|
||||
if (!selectedLead || !generatedLink) return;
|
||||
|
||||
const industryLabel = getIndustryLabel(industryPreset);
|
||||
const subject = encodeURIComponent(`[SAM] ${selectedLead.company} 맞춤형 데모 링크`);
|
||||
const body = encodeURIComponent(
|
||||
`안녕하세요 ${selectedLead.name}님,\n\n` +
|
||||
`${selectedLead.company}의 ${industryLabel} 산업 환경에 최적화된 SAM MES 데모를 준비했습니다.\n\n` +
|
||||
`아래 링크를 통해 ${demoDuration}일간 무료로 체험하실 수 있습니다:\n` +
|
||||
`${generatedLink}\n\n` +
|
||||
`궁금하신 점이 있으시면 언제든 연락 주세요.\n\n` +
|
||||
`감사합니다.\n` +
|
||||
`SAM 영업팀 드림`
|
||||
);
|
||||
|
||||
window.open(`mailto:${selectedLead.email}?subject=${subject}&body=${body}`);
|
||||
|
||||
toast.success("이메일 클라이언트가 열렸습니다!");
|
||||
};
|
||||
|
||||
const handleOpenDemo = (lead: Lead) => {
|
||||
// Load demo config and start demo directly
|
||||
const demoConfigs = JSON.parse(localStorage.getItem("demoConfigs") || "{}");
|
||||
const demoId = lead.demoLink?.split("/demo/")[1];
|
||||
|
||||
if (demoId && demoConfigs[demoId]) {
|
||||
const config = demoConfigs[demoId];
|
||||
// Check if expired
|
||||
const expiryDate = new Date(config.expiryDate);
|
||||
const now = new Date();
|
||||
|
||||
if (now < expiryDate) {
|
||||
console.log("Opening demo with config:", config);
|
||||
if (onStartDemo) {
|
||||
onStartDemo(config);
|
||||
}
|
||||
} else {
|
||||
toast.error("이 데모는 만료되었습니다.");
|
||||
}
|
||||
} else {
|
||||
console.error("Demo config not found. Demo ID:", demoId);
|
||||
console.error("Available configs:", demoConfigs);
|
||||
toast.error("데모 설정을 찾을 수 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-300"><Clock className="w-3 h-3 mr-1" />대기중</Badge>;
|
||||
case "contacted":
|
||||
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-300"><Phone className="w-3 h-3 mr-1" />연락완료</Badge>;
|
||||
case "demo-sent":
|
||||
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-300"><CheckCircle2 className="w-3 h-3 mr-1" />데모전송</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">알수없음</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getIndustryLabel = (industry: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
"automotive": "자동차 부품",
|
||||
"electronics": "전자/전기",
|
||||
"machinery": "기계/설비",
|
||||
"food": "식품 가공",
|
||||
"chemical": "화학/제약",
|
||||
"plastic": "플라스틱/고무",
|
||||
"metal": "금속 가공",
|
||||
"other": "기타"
|
||||
};
|
||||
return labels[industry] || industry;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-purple-50 via-white to-blue-50">
|
||||
{/* Header */}
|
||||
<header className="backdrop-blur-sm bg-white/80 border-b border-gray-200/50 sticky top-0 z-50">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate("/")}
|
||||
className="rounded-xl hover:bg-gray-100"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
랜딩페이지로
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shadow-md bg-gradient-to-br from-purple-500 to-purple-600">
|
||||
<div className="text-white font-bold text-lg">S</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-gray-900">SAM 영업 대시보드</h1>
|
||||
<p className="text-xs text-gray-500">리드 관리 및 데모 생성</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge className="bg-purple-100 text-purple-700 px-4 py-2">
|
||||
<Briefcase className="w-4 h-4 mr-2" />
|
||||
영업사원 전용
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 mb-2">리드 관리</h2>
|
||||
<p className="text-gray-600">고객의 데모 요청을 확인하고 맞춤형 데모를 생성하세요</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">전체 리드</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{leads.length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">대기중</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">{leads.filter(l => l.status === "pending").length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-yellow-100 flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">연락완료</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{leads.filter(l => l.status === "contacted").length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<Phone className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">데모전송</p>
|
||||
<p className="text-3xl font-bold text-green-600">{leads.filter(l => l.status === "demo-sent").length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Leads Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>리드 목록</CardTitle>
|
||||
<CardDescription>클라이언트의 데모 요청 정보를 확인하세요</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{leads.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<User className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-2">아직 접수된 리드가 없습니다</p>
|
||||
<p className="text-sm text-gray-400">클라이언트가 데모를 요청하면 여기에 표시됩니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{leads.map((lead) => (
|
||||
<div
|
||||
key={lead.id}
|
||||
className="border border-gray-200 rounded-xl p-6 hover:shadow-lg transition-shadow bg-white"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-bold text-gray-900 text-lg">{lead.name}</h3>
|
||||
{getStatusBadge(lead.status)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-gray-400" />
|
||||
{lead.company}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4 text-gray-400" />
|
||||
{getIndustryLabel(lead.industry)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-gray-400" />
|
||||
{lead.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
{lead.phone}
|
||||
</div>
|
||||
</div>
|
||||
{lead.message && (
|
||||
<div className="mt-3 flex items-start gap-2 text-sm">
|
||||
<MessageSquare className="w-4 h-4 text-gray-400 mt-0.5" />
|
||||
<p className="text-gray-600 italic">{lead.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
요청일: {new Date(lead.submittedAt).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{lead.status === "demo-sent" && lead.demoLink ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const link = lead.demoLink!;
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
toast.success("링크가 복사되었습니다!");
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = link;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
toast.success("링크가 복사되었습니다!");
|
||||
} catch (err) {
|
||||
toast.error("복사 실패. 수동으로 복사해주세요.");
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-secure contexts
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = link;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
toast.success("링크가 복사되었습니다!");
|
||||
} catch (err) {
|
||||
toast.error("복사 실패. 수동으로 복사해주세요.");
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
링크 복사
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenDemo(lead)}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
데모 열기
|
||||
</Button>
|
||||
{lead.demoExpiryDate && (
|
||||
<p className="text-xs text-gray-500 text-center mt-1">
|
||||
만료: {new Date(lead.demoExpiryDate).toLocaleDateString('ko-KR')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCreateDemo(lead)}
|
||||
className="rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
데모 생성
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Create Demo Modal */}
|
||||
<Dialog open={isCreateDemoOpen} onOpenChange={setIsCreateDemoOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">맞춤형 데모 생성</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedLead && `${selectedLead.name}님을 위한 데모를 설정하세요`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedLead && (
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* Client Info */}
|
||||
<div className="bg-blue-50 rounded-xl p-4 border border-blue-200">
|
||||
<h4 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-blue-600" />
|
||||
클라이언트 정보
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><span className="text-gray-600">담당자:</span> <span className="font-semibold">{selectedLead.name}</span></div>
|
||||
<div><span className="text-gray-600">회사:</span> <span className="font-semibold">{selectedLead.company}</span></div>
|
||||
<div><span className="text-gray-600">이메일:</span> <span className="font-semibold">{selectedLead.email}</span></div>
|
||||
<div><span className="text-gray-600">연락처:</span> <span className="font-semibold">{selectedLead.phone}</span></div>
|
||||
<div className="col-span-2"><span className="text-gray-600">산업분야:</span> <span className="font-semibold">{getIndustryLabel(selectedLead.industry)}</span></div>
|
||||
{selectedLead.message && (
|
||||
<div className="col-span-2"><span className="text-gray-600">요청사항:</span> <span className="font-semibold">{selectedLead.message}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Settings */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="industryPreset" className="text-sm font-semibold flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4 text-purple-600" />
|
||||
산업별 프리셋 *
|
||||
</Label>
|
||||
<Select value={industryPreset} onValueChange={setIndustryPreset} required>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="산업 분야를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="automotive">자동차 부품</SelectItem>
|
||||
<SelectItem value="electronics">전자/전기</SelectItem>
|
||||
<SelectItem value="machinery">기계/설비</SelectItem>
|
||||
<SelectItem value="food">식품 가공</SelectItem>
|
||||
<SelectItem value="chemical">화학/제약</SelectItem>
|
||||
<SelectItem value="plastic">플라스틱/고무</SelectItem>
|
||||
<SelectItem value="metal">금속 가공</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="demoDuration" className="text-sm font-semibold flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-purple-600" />
|
||||
데모 체험 기간 *
|
||||
</Label>
|
||||
<Select value={demoDuration} onValueChange={setDemoDuration} required>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue placeholder="체험 기간을 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3일</SelectItem>
|
||||
<SelectItem value="7">7일 (권장)</SelectItem>
|
||||
<SelectItem value="14">14일</SelectItem>
|
||||
<SelectItem value="30">30일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generated Link */}
|
||||
{generatedLink && (
|
||||
<div className="bg-green-50 rounded-xl p-4 border border-green-200">
|
||||
<h4 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
생성된 데모 링크
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={generatedLink}
|
||||
readOnly
|
||||
className="flex-1 bg-white"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCopyLink}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{isCopied ? <CheckCircle2 className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
만료일: {new Date(Date.now() + parseInt(demoDuration) * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
|
||||
{!generatedLink ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDemoOpen(false)}
|
||||
className="flex-1 h-12"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerateLink}
|
||||
disabled={!industryPreset || !demoDuration}
|
||||
className="flex-1 h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-semibold"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
데모 링크 생성
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDemoOpen(false)}
|
||||
className="flex-1 h-12"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedLead) {
|
||||
handleOpenDemo(selectedLead);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1 h-12 border-2 border-purple-500 text-purple-700 hover:bg-purple-50 font-semibold"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
데모 열기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
className="flex-1 h-12 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-semibold"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
이메일 전송
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Plus, Download } from "lucide-react";
|
||||
import { QuoteCreation, QuoteList } from "./QuoteCreation";
|
||||
|
||||
export function SalesManagement() {
|
||||
const [isQuoteCreationOpen, setIsQuoteCreationOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">판매관리</h1>
|
||||
<p className="text-muted-foreground">견적, 수주, 거래처 및 설치 일정 통합 관리</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline" className="border-border/50">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Excel 다운로드
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground"
|
||||
onClick={() => setIsQuoteCreationOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
신규 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 견적 산출하기 모달 */}
|
||||
<Dialog open={isQuoteCreationOpen} onOpenChange={setIsQuoteCreationOpen}>
|
||||
<DialogContent className="max-w-[95vw] w-[95vw] max-h-[95vh] h-[95vh] overflow-hidden p-0">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<QuoteCreation onClose={() => setIsQuoteCreationOpen(false)} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 견적 목록 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-6">
|
||||
<h2 className="text-xl font-bold mb-4">견적 목록</h2>
|
||||
<QuoteList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus, Download } from "lucide-react";
|
||||
import { QuoteCreation, QuoteList, OrderList, OrderRegistration } from "./QuoteCreation";
|
||||
|
||||
export function SalesManagement() {
|
||||
const [isQuoteCreationOpen, setIsQuoteCreationOpen] = useState(false);
|
||||
const [isOrderRegistrationOpen, setIsOrderRegistrationOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("quote");
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">판매관리</h1>
|
||||
<p className="text-muted-foreground">견적, 수주, 거래처 및 설치 일정 통합 관리</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline" className="border-border/50">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Excel 다운로드
|
||||
</Button>
|
||||
{activeTab === "quote" && (
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground"
|
||||
onClick={() => setIsQuoteCreationOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
신규 등록
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === "order" && (
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground"
|
||||
onClick={() => setIsOrderRegistrationOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
수주 등록하기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 견적 산출하기 모달 */}
|
||||
<Dialog open={isQuoteCreationOpen} onOpenChange={setIsQuoteCreationOpen}>
|
||||
<DialogContent className="max-w-[95vw] w-[95vw] max-h-[95vh] h-[95vh] overflow-hidden p-0">
|
||||
<DialogTitle className="sr-only">견적 산출하기</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
새로운 견적을 산출하기 위한 정보를 입력합니다.
|
||||
</DialogDescription>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<QuoteCreation onClose={() => setIsQuoteCreationOpen(false)} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수주 등록하기 모달 */}
|
||||
<Dialog open={isOrderRegistrationOpen} onOpenChange={setIsOrderRegistrationOpen}>
|
||||
<DialogContent className="max-w-[95vw] w-[95vw] max-h-[95vh] h-[95vh] overflow-hidden p-0">
|
||||
<DialogTitle className="sr-only">수주 등록하기</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
새로운 수주를 등록합니다.
|
||||
</DialogDescription>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<OrderRegistration onClose={() => setIsOrderRegistrationOpen(false)} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 견적/수주 탭 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="quote">견적 목록</TabsTrigger>
|
||||
<TabsTrigger value="order">수주 목록</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="quote" className="mt-6">
|
||||
<QuoteList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="order" className="mt-6">
|
||||
<OrderList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,524 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Lock,
|
||||
Tag,
|
||||
CheckCircle2,
|
||||
Briefcase,
|
||||
Users,
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
// 회사 정보
|
||||
companyName: "",
|
||||
businessNumber: "",
|
||||
industry: "",
|
||||
companySize: "",
|
||||
|
||||
// 담당자 정보
|
||||
name: "",
|
||||
position: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
userId: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
|
||||
// 플랜 및 추천인
|
||||
plan: "demo",
|
||||
salesCode: "",
|
||||
|
||||
// 약관
|
||||
agreeTerms: false,
|
||||
agreePrivacy: false,
|
||||
});
|
||||
|
||||
const [salesCodeValid, setSalesCodeValid] = useState<boolean | null>(null);
|
||||
const [discount, setDiscount] = useState(0);
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const validateSalesCode = (code: string) => {
|
||||
// 영업사원 코드 검증 로직 (실제로는 API 호출)
|
||||
const validCodes: { [key: string]: number } = {
|
||||
"SALES2024": 20,
|
||||
"PARTNER30": 30,
|
||||
"VIP50": 50,
|
||||
};
|
||||
|
||||
if (validCodes[code]) {
|
||||
setSalesCodeValid(true);
|
||||
setDiscount(validCodes[code]);
|
||||
} else if (code === "") {
|
||||
setSalesCodeValid(null);
|
||||
setDiscount(0);
|
||||
} else {
|
||||
setSalesCodeValid(false);
|
||||
setDiscount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSalesCodeChange = (code: string) => {
|
||||
handleInputChange("salesCode", code);
|
||||
validateSalesCode(code);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 회원가입 처리 (실제로는 API 호출)
|
||||
const userData = {
|
||||
...formData,
|
||||
discount,
|
||||
role: "CEO", // 기본 역할
|
||||
};
|
||||
|
||||
// Save user data to localStorage
|
||||
localStorage.setItem("user", JSON.stringify(userData));
|
||||
|
||||
// Navigate to dashboard
|
||||
navigate("/dashboard");
|
||||
};
|
||||
|
||||
const isStep1Valid = formData.companyName && formData.businessNumber && formData.industry && formData.companySize;
|
||||
const isStep2Valid = formData.name && formData.email && formData.phone && formData.userId && formData.password && formData.password === formData.passwordConfirm;
|
||||
const isStep3Valid = formData.agreeTerms && formData.agreePrivacy;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="clean-glass border-b border-border">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="flex items-center space-x-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center clean-shadow relative overflow-hidden" style={{ backgroundColor: '#3B82F6' }}>
|
||||
<div className="text-white font-bold text-lg">S</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent opacity-30"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-wide">SAM</h1>
|
||||
<p className="text-xs text-muted-foreground">회원가입</p>
|
||||
</div>
|
||||
</button>
|
||||
<Button variant="ghost" onClick={() => navigate("/login")} className="rounded-xl">
|
||||
로그인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 container mx-auto px-6 py-12">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{[1, 2, 3].map((stepNumber) => (
|
||||
<div key={stepNumber} className="flex items-center flex-1">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-colors ${
|
||||
step >= stepNumber
|
||||
? "bg-primary text-white"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{stepNumber}
|
||||
</div>
|
||||
{stepNumber < 3 && (
|
||||
<div className={`flex-1 h-1 mx-4 rounded transition-colors ${
|
||||
step > stepNumber ? "bg-primary" : "bg-muted"
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className={step >= 1 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
회사 정보
|
||||
</span>
|
||||
<span className={step >= 2 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
담당자 정보
|
||||
</span>
|
||||
<span className={step >= 3 ? "text-foreground font-medium" : "text-muted-foreground"}>
|
||||
플랜 선택
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: 회사 정보 */}
|
||||
{step === 1 && (
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-foreground">회사 정보를 입력해주세요</h2>
|
||||
<p className="text-muted-foreground">MES 시스템을 도입할 회사의 기본 정보를 알려주세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="companyName" className="flex items-center space-x-2 mb-2">
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>회사명 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
placeholder="예: 삼성전자"
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleInputChange("companyName", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="businessNumber" className="flex items-center space-x-2 mb-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>사업자등록번호 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="businessNumber"
|
||||
placeholder="000-00-00000"
|
||||
value={formData.businessNumber}
|
||||
onChange={(e) => handleInputChange("businessNumber", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="industry" className="flex items-center space-x-2 mb-2">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
<span>업종 *</span>
|
||||
</Label>
|
||||
<Select value={formData.industry} onValueChange={(value) => handleInputChange("industry", value)}>
|
||||
<SelectTrigger className="clean-input">
|
||||
<SelectValue placeholder="업종을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="electronics">전자/반도체</SelectItem>
|
||||
<SelectItem value="machinery">기계/장비</SelectItem>
|
||||
<SelectItem value="automotive">자동차/부품</SelectItem>
|
||||
<SelectItem value="chemical">화학/소재</SelectItem>
|
||||
<SelectItem value="food">식품/제약</SelectItem>
|
||||
<SelectItem value="textile">섬유/의류</SelectItem>
|
||||
<SelectItem value="metal">금속/철강</SelectItem>
|
||||
<SelectItem value="other">기타 제조업</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="companySize" className="flex items-center space-x-2 mb-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>기업 규모 *</span>
|
||||
</Label>
|
||||
<Select value={formData.companySize} onValueChange={(value) => handleInputChange("companySize", value)}>
|
||||
<SelectTrigger className="clean-input">
|
||||
<SelectValue placeholder="기업 규모를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">중소기업 (직원 10-50명)</SelectItem>
|
||||
<SelectItem value="medium">중견기업 (직원 50-300명)</SelectItem>
|
||||
<SelectItem value="large">대기업 (직원 300명 이상)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!isStep1Valid}
|
||||
className="w-full rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
다음 단계
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 담당자 정보 */}
|
||||
{step === 2 && (
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-foreground">담당자 정보를 입력해주세요</h2>
|
||||
<p className="text-muted-foreground">시스템 관리자 계정으로 사용될 정보입니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="flex items-center space-x-2 mb-2">
|
||||
<User className="w-4 h-4"/>
|
||||
<span>성명 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="position" className="flex items-center space-x-2 mb-2">
|
||||
<Briefcase className="w-4 h-4"/>
|
||||
<span>직책</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="position"
|
||||
placeholder="예: 생산관리팀장"
|
||||
value={formData.position}
|
||||
onChange={(e) => handleInputChange("position", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="flex items-center space-x-2 mb-2">
|
||||
<Mail className="w-4 h-4"/>
|
||||
<span>이메일 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone" className="flex items-center space-x-2 mb-2">
|
||||
<Phone className="w-4 h-4"/>
|
||||
<span>연락처 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="010-0000-0000"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange("phone", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="userId" className="flex items-center space-x-2 mb-2">
|
||||
<User className="w-4 h-4"/>
|
||||
<span>아이디 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
placeholder="영문, 숫자 조합 6자 이상"
|
||||
value={formData.userId}
|
||||
onChange={(e) => handleInputChange("userId", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4"/>
|
||||
<span>비밀번호 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="8자 이상 입력"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="passwordConfirm" className="flex items-center space-x-2 mb-2">
|
||||
<Lock className="w-4 h-4"/>
|
||||
<span>비밀번호 확인 *</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="passwordConfirm"
|
||||
type="password"
|
||||
placeholder="비밀번호 재입력"
|
||||
value={formData.passwordConfirm}
|
||||
onChange={(e) => handleInputChange("passwordConfirm", e.target.value)}
|
||||
className="clean-input"
|
||||
/>
|
||||
{formData.passwordConfirm && formData.password !== formData.passwordConfirm && (
|
||||
<p className="text-sm text-destructive mt-1">비밀번호가 일치하지 않습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1 rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2"/>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!isStep2Valid}
|
||||
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
다음 단계
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: 플랜 선택 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div>
|
||||
<h2 className="mb-2 text-foreground">플랜을 선택해주세요</h2>
|
||||
<p className="text-muted-foreground">먼저 30일 무료 체험으로 시작해보세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ id: "demo", name: "데모 체험판", desc: "30일 무료 체험 (모든 기능 이용)", badge: "추천" },
|
||||
{ id: "standard", name: "스탠다드", desc: "중소기업 최적화 플랜" },
|
||||
{ id: "premium", name: "프리미엄", desc: "중견기업 맞춤형 플랜" },
|
||||
].map((plan) => (
|
||||
<button
|
||||
key={plan.id}
|
||||
onClick={() => handleInputChange("plan", plan.id)}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
formData.plan === plan.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">{plan.name}</span>
|
||||
{plan.badge && (
|
||||
<Badge className="bg-primary text-white text-xs">
|
||||
{plan.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{plan.desc}</p>
|
||||
</div>
|
||||
{formData.plan === plan.id && (
|
||||
<CheckCircle2 className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="salesCode" className="flex items-center space-x-2 mb-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
<span>영업사원 추천코드 (선택)</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="salesCode"
|
||||
placeholder="추천코드를 입력하면 할인 혜택을 받을 수 있습니다"
|
||||
value={formData.salesCode}
|
||||
onChange={(e) => handleSalesCodeChange(e.target.value)}
|
||||
className={`clean-input pr-10 ${
|
||||
salesCodeValid === true ? "border-green-500" :
|
||||
salesCodeValid === false ? "border-destructive" : ""
|
||||
}`}
|
||||
/>
|
||||
{salesCodeValid === true && (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</div>
|
||||
{salesCodeValid === true && (
|
||||
<p className="text-sm text-green-600 mt-2 flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>유효한 코드입니다! {discount}% 할인이 적용됩니다</span>
|
||||
</p>
|
||||
)}
|
||||
{salesCodeValid === false && (
|
||||
<p className="text-sm text-destructive mt-2">유효하지 않은 코드입니다</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
💡 예시 코드: SALES2024 (20%), PARTNER30 (30%), VIP50 (50%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-border">
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.agreeTerms}
|
||||
onChange={(e) => handleInputChange("agreeTerms", e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-foreground">[필수]</span> 서비스 이용약관에 동의합니다
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.agreePrivacy}
|
||||
onChange={(e) => handleInputChange("agreePrivacy", e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-foreground">[필수]</span> 개인정보 수집 및 이용에 동의합니다
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep(2)}
|
||||
className="flex-1 rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isStep3Valid}
|
||||
className="flex-1 rounded-xl bg-primary hover:bg-primary/90"
|
||||
>
|
||||
가입 완료
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
<button
|
||||
onClick={() => navigate("/login")}
|
||||
className="text-primary font-medium hover:underline"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,573 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Server,
|
||||
Database,
|
||||
Users,
|
||||
Shield,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Network,
|
||||
Wifi,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
Settings,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Lock,
|
||||
Unlock
|
||||
} from "lucide-react";
|
||||
import { LineChart, Line, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
|
||||
export function SystemAdminDashboard() {
|
||||
// 시스템 상태 데이터
|
||||
const systemStatus = {
|
||||
servers: {
|
||||
total: 8,
|
||||
online: 7,
|
||||
offline: 1,
|
||||
warning: 2
|
||||
},
|
||||
database: {
|
||||
connections: 45,
|
||||
maxConnections: 100,
|
||||
queryPerformance: 98.5,
|
||||
backupStatus: "완료",
|
||||
lastBackup: "2024-12-30 02:00"
|
||||
},
|
||||
users: {
|
||||
total: 125,
|
||||
active: 89,
|
||||
inactive: 36,
|
||||
newToday: 3,
|
||||
loginToday: 67
|
||||
},
|
||||
security: {
|
||||
threatLevel: "낮음",
|
||||
blockedAttacks: 12,
|
||||
securityEvents: 3,
|
||||
lastScan: "2024-12-30 08:00"
|
||||
}
|
||||
};
|
||||
|
||||
// 서버 리소스 데이터
|
||||
const serverResources = [
|
||||
{ name: "WEB-01", cpu: 45, memory: 62, disk: 78, status: "정상" },
|
||||
{ name: "DB-01", cpu: 78, memory: 85, disk: 45, status: "주의" },
|
||||
{ name: "APP-01", cpu: 32, memory: 48, disk: 67, status: "정상" },
|
||||
{ name: "API-01", cpu: 89, memory: 92, disk: 56, status: "경고" },
|
||||
{ name: "FILE-01", cpu: 23, memory: 34, disk: 89, status: "정상" },
|
||||
{ name: "BACKUP-01", cpu: 15, memory: 28, disk: 95, status: "주의" }
|
||||
];
|
||||
|
||||
// 시스템 사용량 트렌드
|
||||
const usageTrend = [
|
||||
{ time: "00:00", cpu: 25, memory: 40, network: 15 },
|
||||
{ time: "04:00", cpu: 20, memory: 35, network: 10 },
|
||||
{ time: "08:00", cpu: 65, memory: 70, network: 45 },
|
||||
{ time: "12:00", cpu: 85, memory: 80, network: 65 },
|
||||
{ time: "16:00", cpu: 90, memory: 85, network: 70 },
|
||||
{ time: "20:00", cpu: 75, memory: 75, network: 55 },
|
||||
{ time: "23:59", cpu: 45, memory: 55, network: 35 }
|
||||
];
|
||||
|
||||
// 사용자 활동 분석
|
||||
const userActivity = [
|
||||
{ name: "로그인", value: 245, color: "#1428A0" },
|
||||
{ name: "작업", value: 189, color: "#00D084" },
|
||||
{ name: "보고서", value: 156, color: "#FF6B35" },
|
||||
{ name: "승인", value: 89, color: "#8B5FBF" },
|
||||
{ name: "시스템", value: 45, color: "#FF4444" }
|
||||
];
|
||||
|
||||
// 보안 이벤트 로그
|
||||
const securityEvents = [
|
||||
{ time: "09:15", event: "성공적인 로그인", user: "김대표", ip: "192.168.1.100", severity: "정보" },
|
||||
{ time: "09:12", event: "비정상 접근 차단", user: "Unknown", ip: "203.142.15.23", severity: "경고" },
|
||||
{ time: "08:45", event: "권한 변경", user: "최시스템", ip: "192.168.1.105", severity: "정보" },
|
||||
{ time: "08:30", event: "다중 로그인 실패", user: "이생산", ip: "192.168.1.110", severity: "주의" },
|
||||
{ time: "08:15", event: "백업 완료", user: "System", ip: "Local", severity: "정보" }
|
||||
];
|
||||
|
||||
// 데이터베이스 성능 지표
|
||||
const dbPerformance = [
|
||||
{ metric: "평균 응답시간", value: "12ms", status: "excellent" },
|
||||
{ metric: "동시 연결", value: "45/100", status: "good" },
|
||||
{ metric: "쿼리 처리량", value: "1,250/분", status: "good" },
|
||||
{ metric: "인덱스 효율성", value: "98.5%", status: "excellent" },
|
||||
{ metric: "캐시 적중률", value: "94.2%", status: "excellent" },
|
||||
{ metric: "디스크 I/O", value: "2.3MB/s", status: "good" }
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "정상": return "bg-green-500";
|
||||
case "주의": return "bg-yellow-500";
|
||||
case "경고": return "bg-orange-500";
|
||||
case "오프라인": return "bg-red-500";
|
||||
default: return "bg-muted";
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "정보": return "text-blue-600 bg-blue-50";
|
||||
case "주의": return "text-yellow-600 bg-yellow-50";
|
||||
case "경고": return "text-orange-600 bg-orange-50";
|
||||
case "위험": return "text-red-600 bg-red-50";
|
||||
default: return "text-muted-foreground bg-muted/50";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">시스템 관리 대시보드</h1>
|
||||
<p className="text-muted-foreground mt-1">SAM 시스템 전체 현황 및 관리</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button size="sm" className="samsung-button">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시스템 상태 개요 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">서버 상태</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{systemStatus.servers.online}/{systemStatus.servers.total}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">온라인/전체</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge className="bg-green-500 text-white mb-1">
|
||||
{systemStatus.servers.online}대 정상
|
||||
</Badge>
|
||||
{systemStatus.servers.warning > 0 && (
|
||||
<Badge className="bg-yellow-500 text-white block">
|
||||
{systemStatus.servers.warning}대 주의
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">데이터베이스</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{systemStatus.database.connections}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
/{systemStatus.database.maxConnections} 연결
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(systemStatus.database.connections / systemStatus.database.maxConnections) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
성능: {systemStatus.database.queryPerformance}%
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">사용자 현황</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{systemStatus.users.active}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
/{systemStatus.users.total} 활성
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-green-600">
|
||||
<UserCheck className="w-3 h-3 inline mr-1" />
|
||||
오늘 로그인: {systemStatus.users.loginToday}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
신규 가입: {systemStatus.users.newToday}명
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">보안 상태</CardTitle>
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className="bg-green-500 text-white">
|
||||
{systemStatus.security.threatLevel}
|
||||
</Badge>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>차단된 공격:</span>
|
||||
<span className="font-medium">{systemStatus.security.blockedAttacks}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>보안 이벤트:</span>
|
||||
<span className="font-medium">{systemStatus.security.securityEvents}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 시스템 리소스 및 성능 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
<span>시스템 사용량 트렌드</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={usageTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis dataKey="time" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip />
|
||||
<Area type="monotone" dataKey="cpu" stackId="1" stroke="#1428A0" fill="#1428A0" name="CPU %" />
|
||||
<Area type="monotone" dataKey="memory" stackId="1" stroke="#00D084" fill="#00D084" name="메모리 %" />
|
||||
<Area type="monotone" dataKey="network" stackId="1" stroke="#FF6B35" fill="#FF6B35" name="네트워크 %" />
|
||||
<Legend />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Users className="w-5 h-5" />
|
||||
<span>사용자 활동 분석</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={userActivity}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{userActivity.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 서버 리소스 상세 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Server className="w-5 h-5" />
|
||||
<span>서버 리소스 현황</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serverResources.map((server) => (
|
||||
<div key={server.name} className="p-4 border rounded-2xl bg-muted/50 dark:bg-muted/20">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h4 className="font-semibold">{server.name}</h4>
|
||||
<Badge className={getStatusColor(server.status)}>
|
||||
{server.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="flex items-center">
|
||||
<Cpu className="w-3 h-3 mr-1" />
|
||||
CPU
|
||||
</span>
|
||||
<span>{server.cpu}%</span>
|
||||
</div>
|
||||
<Progress value={server.cpu} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="flex items-center">
|
||||
<MemoryStick className="w-3 h-3 mr-1" />
|
||||
메모리
|
||||
</span>
|
||||
<span>{server.memory}%</span>
|
||||
</div>
|
||||
<Progress value={server.memory} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="flex items-center">
|
||||
<HardDrive className="w-3 h-3 mr-1" />
|
||||
디스크
|
||||
</span>
|
||||
<span>{server.disk}%</span>
|
||||
</div>
|
||||
<Progress value={server.disk} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 데이터베이스 성능 및 보안 로그 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Database className="w-5 h-5" />
|
||||
<span>데이터베이스 성능</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{dbPerformance.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-3 bg-muted/50 dark:bg-muted/20 rounded-lg">
|
||||
<span className="font-medium">{item.metric}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">{item.value}</span>
|
||||
{item.status === "excellent" && <CheckCircle className="w-4 h-4 text-green-500" />}
|
||||
{item.status === "good" && <CheckCircle className="w-4 h-4 text-blue-500" />}
|
||||
{item.status === "warning" && <AlertTriangle className="w-4 h-4 text-yellow-500" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">마지막 백업</span>
|
||||
<span className="text-sm text-blue-600">{systemStatus.database.lastBackup}</span>
|
||||
</div>
|
||||
<Badge className="bg-green-500 text-white mt-2">
|
||||
{systemStatus.database.backupStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>보안 이벤트 로그</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{securityEvents.map((event, index) => (
|
||||
<div key={index} className="flex justify-between items-start p-3 border rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-sm font-medium">{event.event}</span>
|
||||
<Badge className={`text-xs ${getSeverityColor(event.severity)}`}>
|
||||
{event.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div>사용자: {event.user}</div>
|
||||
<div>IP: {event.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{event.time}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="flex-1">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
전체 로그 보기
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 시스템 알림 및 작업 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>시스템 알림</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-yellow-50 border-l-4 border-yellow-400 rounded">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-sm">DB-01 메모리 사용량 높음</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">85% 사용 중 - 주의 필요</p>
|
||||
</div>
|
||||
<Badge className="bg-yellow-500 text-white text-xs">주의</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-50 border-l-4 border-orange-400 rounded">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-sm">API-01 CPU 과부하</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">89% 사용 중 - 확인 필요</p>
|
||||
</div>
|
||||
<Badge className="bg-orange-500 text-white text-xs">경고</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 border-l-4 border-green-400 rounded">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-sm">백업 완료</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">오늘 02:00 자동 백업 성공</p>
|
||||
</div>
|
||||
<Badge className="bg-green-500 text-white text-xs">정보</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
<span>빠른 작업</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
사용자 관리
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
데이터베이스 백업
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
보안 스캔 실행
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
시스템 로그 내보내기
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
서비스 재시작
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
<span>시스템 상태 요약</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">전체 서버</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium">87.5% 정상</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">데이터베이스</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium">98.5% 성능</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">보안 상태</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium">안전</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">사용자 활동</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium">71% 활성</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 border-t">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
마지막 업데이트: {new Date().toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,985 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SystemAdminDashboard } from "./SystemAdminDashboard";
|
||||
import { UserManagement } from "./UserManagement";
|
||||
import MenuCustomization from "./MenuCustomization";
|
||||
import {
|
||||
Users,
|
||||
Settings,
|
||||
Database,
|
||||
Shield,
|
||||
Server,
|
||||
Activity,
|
||||
Bell,
|
||||
FileText,
|
||||
Download,
|
||||
Upload,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
Lock,
|
||||
Key,
|
||||
HardDrive,
|
||||
Monitor,
|
||||
Wifi,
|
||||
Code,
|
||||
Eye,
|
||||
Search,
|
||||
Filter,
|
||||
Plus
|
||||
} from "lucide-react";
|
||||
|
||||
interface SystemManagementProps {
|
||||
userRole?: string;
|
||||
defaultTab?: string;
|
||||
}
|
||||
|
||||
export function SystemManagement({ userRole, defaultTab = "dashboard" }: SystemManagementProps) {
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
// Update activeTab when defaultTab prop changes
|
||||
useEffect(() => {
|
||||
if (defaultTab) {
|
||||
setActiveTab(defaultTab);
|
||||
}
|
||||
}, [defaultTab]);
|
||||
|
||||
// 시스템관리자가 아닌 경우 기본 인사관리 화면 표시
|
||||
if (userRole !== "SystemAdmin") {
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">인사 관리</h1>
|
||||
<p className="text-muted-foreground mt-1">직원 정보 및 조직 관리</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
인사 보고서
|
||||
</Button>
|
||||
<Button>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
데이터 가져오기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인사 현황 개요 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">전체 직원</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-primary">125</div>
|
||||
<p className="text-xs text-muted-foreground">명</p>
|
||||
<div className="flex items-center pt-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">전월 대비 +3명</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">출근율</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">96.8%</div>
|
||||
<p className="text-xs text-muted-foreground">오늘 기준</p>
|
||||
<div className="flex items-center pt-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">121명 출근</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">휴가 중</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">8</div>
|
||||
<p className="text-xs text-muted-foreground">명</p>
|
||||
<div className="flex items-center pt-2">
|
||||
<Clock className="h-4 w-4 text-orange-500 mr-1" />
|
||||
<span className="text-xs text-orange-600">연차 5명, 병가 3명</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">신규 입사</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">3</div>
|
||||
<p className="text-xs text-muted-foreground">명 (이번 달)</p>
|
||||
<div className="flex items-center pt-2">
|
||||
<TrendingUp className="h-4 w-4 text-blue-500 mr-1" />
|
||||
<span className="text-xs text-blue-600">예정 2명 추가</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 부서별 현황 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle>부서별 인력 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ name: "생산부", total: 45, present: 43, absent: 2 },
|
||||
{ name: "품질부", total: 18, present: 18, absent: 0 },
|
||||
{ name: "관리부", total: 22, present: 20, absent: 2 },
|
||||
{ name: "영업부", total: 15, present: 14, absent: 1 },
|
||||
{ name: "연구소", total: 12, present: 11, absent: 1 },
|
||||
{ name: "구매부", total: 8, present: 8, absent: 0 },
|
||||
{ name: "IT부", total: 3, present: 3, absent: 0 },
|
||||
{ name: "재무부", total: 2, present: 2, absent: 0 }
|
||||
].map((dept) => (
|
||||
<div key={dept.name} className="p-4 border rounded-2xl bg-gray-50/50">
|
||||
<h4 className="font-semibold mb-2">{dept.name}</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>전체</span>
|
||||
<span className="font-medium">{dept.total}명</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>출근</span>
|
||||
<span className="font-medium text-green-600">{dept.present}명</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>결근</span>
|
||||
<span className="font-medium text-red-600">{dept.absent}명</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시스템관리자용 인터페이스
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">시스템 관리</h1>
|
||||
<p className="text-muted-foreground mt-1">SAM 시스템 전체 관리 및 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4 lg:grid-cols-8 bg-muted rounded-2xl p-1">
|
||||
<TabsTrigger value="dashboard" className="rounded-xl">
|
||||
<Activity className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">대시보드</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="rounded-xl">
|
||||
<Users className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">사용자</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="menu-customization" className="rounded-xl">
|
||||
<Code className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">메뉴</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="permissions" className="rounded-xl">
|
||||
<Shield className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">권한</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system" className="rounded-xl">
|
||||
<Settings className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">시스템</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="database" className="rounded-xl">
|
||||
<Database className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">DB</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monitoring" className="rounded-xl">
|
||||
<Monitor className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">모니터링</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="rounded-xl">
|
||||
<Lock className="w-4 h-4 mr-0 lg:mr-2" />
|
||||
<span className="hidden lg:inline">보안</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard">
|
||||
<SystemAdminDashboard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users">
|
||||
<UserManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="menu-customization">
|
||||
<MenuCustomization />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="permissions">
|
||||
<PermissionManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system">
|
||||
<SystemSettings />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="database">
|
||||
<DatabaseManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="monitoring">
|
||||
<SystemMonitoring />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security">
|
||||
<SecurityManagement />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 권한 관리 컴포넌트
|
||||
function PermissionManagement() {
|
||||
return (
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>권한 관리</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 역할별 권한 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">역할별 권한 설정</h3>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
역할 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ role: "CEO", name: "대표이사", permissions: ["전체관리", "승인권한", "시스템설정"], count: 1, color: "bg-purple-500" },
|
||||
{ role: "SystemAdmin", name: "시스템관리자", permissions: ["시스템관리", "사용자관리", "권한관리"], count: 1, color: "bg-blue-500" },
|
||||
{ role: "ProductionManager", name: "생산관리자", permissions: ["생산관리", "품질관리", "재고관리"], count: 3, color: "bg-green-500" },
|
||||
{ role: "QualityManager", name: "품질관리자", permissions: ["품질관리", "검사기록", "품질보고서"], count: 2, color: "bg-orange-500" },
|
||||
{ role: "Worker", name: "작업자", permissions: ["작업등록", "작업조회"], count: 89, color: "bg-gray-500" }
|
||||
].map((role) => (
|
||||
<div key={role.role} className="p-4 border rounded-lg bg-gray-50/50">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="font-medium">{role.name}</h4>
|
||||
<Badge className={`${role.color} text-white`}>
|
||||
{role.count}명
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{role.role}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline">
|
||||
<Key className="w-3 h-3 mr-1" />
|
||||
편집
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{role.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="secondary" className="mr-1 mb-1">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모듈별 접근 권한 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">모듈별 접근 권한</h3>
|
||||
<Button size="sm" variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
권한 매트릭스
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ module: "대시보드", roles: ["CEO", "관리자", "매니저"], icon: Activity, color: "border-l-blue-500" },
|
||||
{ module: "재무관리", roles: ["CEO", "재무담당자"], icon: FileText, color: "border-l-green-500" },
|
||||
{ module: "운영관리", roles: ["CEO", "생산관리자", "품질관리자"], icon: Settings, color: "border-l-orange-500" },
|
||||
{ module: "인사관리", roles: ["CEO", "인사담당자"], icon: Users, color: "border-l-purple-500" },
|
||||
{ module: "품질관리", roles: ["CEO", "품질관리자", "생산관리자"], icon: CheckCircle, color: "border-l-red-500" },
|
||||
{ module: "승인관리", roles: ["CEO", "관리자"], icon: Shield, color: "border-l-yellow-500" },
|
||||
{ module: "시스템관리", roles: ["SystemAdmin"], icon: Lock, color: "border-l-indigo-500" }
|
||||
].map((module) => (
|
||||
<div key={module.module} className={`p-4 border-l-4 ${module.color} border rounded-lg bg-gray-50/50`}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<module.icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="font-medium">{module.module}</h4>
|
||||
</div>
|
||||
<Button size="sm" variant="outline">편집</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{module.roles.map((role) => (
|
||||
<Badge key={role} className="bg-blue-500 text-white">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 시스템 설정 컴포넌트
|
||||
function SystemSettings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
<span>일반 설정</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">자동 백업</h4>
|
||||
<p className="text-sm text-muted-foreground">매일 새벽 2시 자동 백업 실행</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-green-600">활성</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">시스템 모니터링</h4>
|
||||
<p className="text-sm text-muted-foreground">실시간 시스템 상태 모니터링</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-green-600">활성</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">로그 자동 정리</h4>
|
||||
<p className="text-sm text-muted-foreground">30일 이상 된 로그 자동 삭제</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-green-600">활성</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">성능 최적화</h4>
|
||||
<p className="text-sm text-muted-foreground">주간 자동 성능 최적화</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-5 h-5 text-orange-500" />
|
||||
<span className="text-sm text-orange-600">예약됨</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>보안 설정</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">비밀번호 정책</h4>
|
||||
<p className="text-sm text-muted-foreground">8자 이상, 특수문자 포함</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-green-600">적용</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">로그인 시도 제한</h4>
|
||||
<p className="text-sm text-muted-foreground">5회 실패 시 계정 잠금</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-green-600">활성</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">세션 만료</h4>
|
||||
<p className="text-sm text-muted-foreground">8시간 비활성 시 자동 로그아웃</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm text-green-600">활성</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<div>
|
||||
<h4 className="font-medium">2단계 인증</h4>
|
||||
<p className="text-sm text-muted-foreground">관리자 계정 2FA 필수</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500" />
|
||||
<span className="text-sm text-yellow-600">선택적</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 시스템 정보 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Server className="w-5 h-5" />
|
||||
<span>시스템 정보</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 border rounded-lg bg-gray-50/50">
|
||||
<h4 className="font-medium mb-2">애플리케이션</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>버전</span>
|
||||
<span className="font-medium">v2.1.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>빌드</span>
|
||||
<span className="font-medium">20241230</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>환경</span>
|
||||
<span className="font-medium">Production</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg bg-gray-50/50">
|
||||
<h4 className="font-medium mb-2">데이터베이스</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>엔진</span>
|
||||
<span className="font-medium">PostgreSQL</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>버전</span>
|
||||
<span className="font-medium">15.4</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>크기</span>
|
||||
<span className="font-medium">2.3GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg bg-gray-50/50">
|
||||
<h4 className="font-medium mb-2">서버</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>OS</span>
|
||||
<span className="font-medium">Ubuntu 22.04</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Node.js</span>
|
||||
<span className="font-medium">v18.17.0</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>메모리</span>
|
||||
<span className="font-medium">16GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg bg-gray-50/50">
|
||||
<h4 className="font-medium mb-2">라이센스</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>타입</span>
|
||||
<span className="font-medium">Enterprise</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>만료일</span>
|
||||
<span className="font-medium">2025-12-31</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>사용자 수</span>
|
||||
<span className="font-medium">무제한</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터베이스 관리 컴포넌트
|
||||
function DatabaseManagement() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Download className="w-5 h-5" />
|
||||
<span>백업 관리</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 border rounded-lg bg-green-50">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">마지막 백업</span>
|
||||
<Badge className="bg-green-500 text-white">성공</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>시간</span>
|
||||
<span className="font-medium">2024-12-30 02:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>크기</span>
|
||||
<span className="font-medium">2.3GB</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>소요시간</span>
|
||||
<span className="font-medium">3분 45초</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">백업 설정</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>자동 백업</span>
|
||||
<span className="font-medium text-green-600">활성</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>주기</span>
|
||||
<span className="font-medium">매일 02:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>보관 기간</span>
|
||||
<span className="font-medium">30일</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 pt-4">
|
||||
<Button className="flex-1">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
백업 생성
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
복원
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
<span>성능 최적화</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">쿼리 성능</span>
|
||||
<span className="font-medium text-green-600">98.5%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">인덱스 최적화</span>
|
||||
<span className="font-medium text-blue-600">완료</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">통계 업데이트</span>
|
||||
<span className="font-medium text-muted-foreground">1시간 전</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">캐시 효율성</span>
|
||||
<span className="font-medium text-green-600">94.2%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-4">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
성능 최적화 실행
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 데이터베이스 상태 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Database className="w-5 h-5" />
|
||||
<span>데이터베이스 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">연결 상태</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm">활성 연결</span>
|
||||
<span className="text-sm font-medium">45/100</span>
|
||||
</div>
|
||||
<Progress value={45} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm">대기 연결</span>
|
||||
<span className="text-sm font-medium">3</span>
|
||||
</div>
|
||||
<Progress value={3} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">스토리지</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm">데이터 크기</span>
|
||||
<span className="text-sm font-medium">2.3GB</span>
|
||||
</div>
|
||||
<Progress value={23} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm">인덱스 크기</span>
|
||||
<span className="text-sm font-medium">890MB</span>
|
||||
</div>
|
||||
<Progress value={8.9} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">성능 지표</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm">쿼리/초</span>
|
||||
<span className="text-sm font-medium">1,250</span>
|
||||
</div>
|
||||
<Progress value={75} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm">응답시간</span>
|
||||
<span className="text-sm font-medium">12ms</span>
|
||||
</div>
|
||||
<Progress value={12} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 시스템 모니터링 컴포넌트
|
||||
function SystemMonitoring() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center flex flex-col items-center space-y-2">
|
||||
<Server className="w-8 h-8 text-green-500" />
|
||||
<span>서버 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">정상</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">모든 서비스 가동 중</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>가동시간</span>
|
||||
<span className="font-medium">28일 14시간</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>응답시간</span>
|
||||
<span className="font-medium">12ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center flex flex-col items-center space-y-2">
|
||||
<Wifi className="w-8 h-8 text-blue-500" />
|
||||
<span>네트워크</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600 mb-2">안정</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">지연시간: 12ms</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>업로드</span>
|
||||
<span className="font-medium">2.3 MB/s</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>다운로드</span>
|
||||
<span className="font-medium">5.7 MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center flex flex-col items-center space-y-2">
|
||||
<HardDrive className="w-8 h-8 text-orange-500" />
|
||||
<span>저장소</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<div className="text-3xl font-bold text-orange-600 mb-2">주의</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">사용률: 78%</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>사용량</span>
|
||||
<span className="font-medium">780GB / 1TB</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>여유공간</span>
|
||||
<span className="font-medium">220GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 실시간 리소스 모니터링 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
<span>실시간 리소스 모니터링</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span>CPU 사용률</span>
|
||||
</h4>
|
||||
<div className="text-2xl font-bold">45%</div>
|
||||
<Progress value={45} className="h-3" />
|
||||
<p className="text-sm text-muted-foreground">평균: 32%</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<span>메모리 사용률</span>
|
||||
</h4>
|
||||
<div className="text-2xl font-bold">62%</div>
|
||||
<Progress value={62} className="h-3" />
|
||||
<p className="text-sm text-muted-foreground">10.2GB / 16GB</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-orange-500 rounded-full"></div>
|
||||
<span>디스크 I/O</span>
|
||||
</h4>
|
||||
<div className="text-2xl font-bold">23%</div>
|
||||
<Progress value={23} className="h-3" />
|
||||
<p className="text-sm text-muted-foreground">읽기: 45MB/s</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span>네트워크</span>
|
||||
</h4>
|
||||
<div className="text-2xl font-bold">12%</div>
|
||||
<Progress value={12} className="h-3" />
|
||||
<p className="text-sm text-muted-foreground">5.7MB/s</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 보안 관리 컴포넌트
|
||||
function SecurityManagement() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span>보안 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 border rounded-lg bg-green-50">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">전체 위협 수준</span>
|
||||
<Badge className="bg-green-500 text-white">낮음</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>차단된 공격</span>
|
||||
<span className="font-medium">12건 (오늘)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>보안 이벤트</span>
|
||||
<span className="font-medium">3건</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>마지막 스캔</span>
|
||||
<span className="font-medium">2시간 전</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
보안 스캔 실행
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Lock className="w-5 h-5" />
|
||||
<span>접근 통제</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">활성 세션</span>
|
||||
<span className="font-medium text-blue-600">89개</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">의심스러운 로그인</span>
|
||||
<span className="font-medium text-green-600">0건</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">차단된 IP</span>
|
||||
<span className="font-medium text-orange-600">3개</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 border rounded-lg">
|
||||
<span className="font-medium">실패한 로그인</span>
|
||||
<span className="font-medium text-red-600">5회</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
로그 상세보기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 보안 이벤트 로그 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
<span>최근 보안 이벤트</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
필터
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ time: "09:15", event: "성공적인 로그인", user: "김대표", ip: "192.168.1.100", severity: "정보", color: "border-l-blue-500 bg-blue-50" },
|
||||
{ time: "09:12", event: "비정상 접근 차단", user: "Unknown", ip: "203.142.15.23", severity: "경고", color: "border-l-orange-500 bg-orange-50" },
|
||||
{ time: "08:45", event: "권한 변경", user: "최시스템", ip: "192.168.1.105", severity: "정보", color: "border-l-blue-500 bg-blue-50" },
|
||||
{ time: "08:30", event: "다중 로그인 실패", user: "이생산", ip: "192.168.1.110", severity: "주의", color: "border-l-yellow-500 bg-yellow-50" },
|
||||
{ time: "08:15", event: "백업 완료", user: "System", ip: "Local", severity: "정보", color: "border-l-green-500 bg-green-50" }
|
||||
].map((event, index) => (
|
||||
<div key={index} className={`p-4 border-l-4 ${event.color} border rounded-lg`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="font-medium">{event.event}</span>
|
||||
<Badge className={`text-xs ${
|
||||
event.severity === '정보' ? 'bg-blue-500 text-white' :
|
||||
event.severity === '주의' ? 'bg-yellow-500 text-white' :
|
||||
event.severity === '경고' ? 'bg-orange-500 text-white' : 'bg-red-500 text-white'
|
||||
}`}>
|
||||
{event.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
사용자: {event.user} | IP: {event.ip}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{event.time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Shield,
|
||||
Lock,
|
||||
Unlock,
|
||||
Calendar,
|
||||
Mail,
|
||||
Phone,
|
||||
Building,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw
|
||||
} from "lucide-react";
|
||||
|
||||
export function UserManagement() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedDepartment, setSelectedDepartment] = useState("all");
|
||||
const [selectedRole, setSelectedRole] = useState("all");
|
||||
const [selectedStatus, setSelectedStatus] = useState("all");
|
||||
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
|
||||
|
||||
// 사용자 데이터
|
||||
const users = [
|
||||
{
|
||||
id: 1,
|
||||
name: "김대표",
|
||||
email: "ceo@company.com",
|
||||
phone: "010-1234-5678",
|
||||
department: "경영진",
|
||||
position: "대표이사",
|
||||
role: "CEO",
|
||||
status: "활성",
|
||||
lastLogin: "2024-12-30 09:15",
|
||||
createdAt: "2024-01-01",
|
||||
permissions: ["전체관리", "승인권한", "시스템설정"]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "이생산",
|
||||
email: "production@company.com",
|
||||
phone: "010-2345-6789",
|
||||
department: "생산부",
|
||||
position: "생산관리자",
|
||||
role: "ProductionManager",
|
||||
status: "활성",
|
||||
lastLogin: "2024-12-30 08:45",
|
||||
createdAt: "2024-01-15",
|
||||
permissions: ["생산관리", "품질관리", "재고관리"]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "박작업",
|
||||
email: "worker@company.com",
|
||||
phone: "010-3456-7890",
|
||||
department: "생산부",
|
||||
position: "생산작업자",
|
||||
role: "Worker",
|
||||
status: "활성",
|
||||
lastLogin: "2024-12-30 07:30",
|
||||
createdAt: "2024-02-01",
|
||||
permissions: ["작업등록", "작업조회"]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "최시스템",
|
||||
email: "sysadmin@company.com",
|
||||
phone: "010-4567-8901",
|
||||
department: "IT부",
|
||||
position: "시스템관리자",
|
||||
role: "SystemAdmin",
|
||||
status: "활성",
|
||||
lastLogin: "2024-12-30 09:00",
|
||||
createdAt: "2024-01-01",
|
||||
permissions: ["시스템관리", "사용자관리", "권한관리", "보안관리"]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "정품질",
|
||||
email: "quality@company.com",
|
||||
phone: "010-5678-9012",
|
||||
department: "품질부",
|
||||
position: "품질관리자",
|
||||
role: "QualityManager",
|
||||
status: "활성",
|
||||
lastLogin: "2024-12-29 18:20",
|
||||
createdAt: "2024-01-20",
|
||||
permissions: ["품질관리", "검사기록", "품질보고서"]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "송구매",
|
||||
email: "purchase@company.com",
|
||||
phone: "010-6789-0123",
|
||||
department: "구매부",
|
||||
position: "구매담당자",
|
||||
role: "PurchaseStaff",
|
||||
status: "비활성",
|
||||
lastLogin: "2024-12-28 17:45",
|
||||
createdAt: "2024-03-01",
|
||||
permissions: ["구매관리", "발주관리"]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "한영업",
|
||||
email: "sales@company.com",
|
||||
phone: "010-7890-1234",
|
||||
department: "영업부",
|
||||
position: "영업담당자",
|
||||
role: "SalesStaff",
|
||||
status: "활성",
|
||||
lastLogin: "2024-12-30 08:15",
|
||||
createdAt: "2024-02-15",
|
||||
permissions: ["고객관리", "주문관리", "견적관리"]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "김회계",
|
||||
email: "accounting@company.com",
|
||||
phone: "010-8901-2345",
|
||||
department: "재무부",
|
||||
position: "회계담당자",
|
||||
role: "AccountingStaff",
|
||||
status: "활성",
|
||||
lastLogin: "2024-12-30 09:30",
|
||||
createdAt: "2024-01-10",
|
||||
permissions: ["재무관리", "회계처리", "예산관리"]
|
||||
}
|
||||
];
|
||||
|
||||
// 부서 목록
|
||||
const departments = ["전체", "경영진", "생산부", "품질부", "구매부", "영업부", "재무부", "IT부"];
|
||||
|
||||
// 역할 목록
|
||||
const roles = [
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "CEO", label: "대표이사" },
|
||||
{ value: "ProductionManager", label: "생산관리자" },
|
||||
{ value: "QualityManager", label: "품질관리자" },
|
||||
{ value: "Worker", label: "작업자" },
|
||||
{ value: "SystemAdmin", label: "시스템관리자" },
|
||||
{ value: "PurchaseStaff", label: "구매담당자" },
|
||||
{ value: "SalesStaff", label: "영업담당자" },
|
||||
{ value: "AccountingStaff", label: "회계담당자" }
|
||||
];
|
||||
|
||||
// 필터링된 사용자 목록
|
||||
const filteredUsers = users.filter(user => {
|
||||
const matchesSearch = user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.department.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesDepartment = selectedDepartment === "all" || user.department === selectedDepartment;
|
||||
const matchesRole = selectedRole === "all" || user.role === selectedRole;
|
||||
const matchesStatus = selectedStatus === "all" || user.status === selectedStatus;
|
||||
|
||||
return matchesSearch && matchesDepartment && matchesRole && matchesStatus;
|
||||
});
|
||||
|
||||
// 사용자 통계
|
||||
const userStats = {
|
||||
total: users.length,
|
||||
active: users.filter(u => u.status === "활성").length,
|
||||
inactive: users.filter(u => u.status === "비활성").length,
|
||||
newThisMonth: users.filter(u => new Date(u.createdAt).getMonth() === new Date().getMonth()).length
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
return status === "활성"
|
||||
? <Badge className="bg-green-500 text-white">활성</Badge>
|
||||
: <Badge className="bg-gray-500 text-white">비활성</Badge>;
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
const roleColors: { [key: string]: string } = {
|
||||
"CEO": "bg-purple-500 text-white",
|
||||
"SystemAdmin": "bg-blue-500 text-white",
|
||||
"ProductionManager": "bg-green-500 text-white",
|
||||
"QualityManager": "bg-orange-500 text-white",
|
||||
"Worker": "bg-gray-500 text-white",
|
||||
"PurchaseStaff": "bg-cyan-500 text-white",
|
||||
"SalesStaff": "bg-pink-500 text-white",
|
||||
"AccountingStaff": "bg-indigo-500 text-white"
|
||||
};
|
||||
|
||||
const roleLabels: { [key: string]: string } = {
|
||||
"CEO": "대표이사",
|
||||
"SystemAdmin": "시스템관리자",
|
||||
"ProductionManager": "생산관리자",
|
||||
"QualityManager": "품질관리자",
|
||||
"Worker": "작업자",
|
||||
"PurchaseStaff": "구매담당자",
|
||||
"SalesStaff": "영업담당자",
|
||||
"AccountingStaff": "회계담당자"
|
||||
};
|
||||
|
||||
return <Badge className={roleColors[role] || "bg-gray-500 text-white"}>
|
||||
{roleLabels[role] || role}
|
||||
</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">사용자 관리</h1>
|
||||
<p className="text-muted-foreground mt-1">시스템 사용자 계정 및 권한 관리</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Dialog open={isAddUserOpen} onOpenChange={setIsAddUserOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="samsung-button">
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
사용자 추가
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 사용자 추가</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름</Label>
|
||||
<Input id="name" placeholder="사용자 이름" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input id="email" type="email" placeholder="email@company.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호</Label>
|
||||
<Input id="phone" placeholder="010-0000-0000" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="부서 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.slice(1).map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">역할</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="역할 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.slice(1).map(role => (
|
||||
<SelectItem key={role.value} value={role.value}>{role.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">임시 비밀번호</Label>
|
||||
<Input id="password" type="password" placeholder="임시 비밀번호" />
|
||||
</div>
|
||||
<div className="flex space-x-2 pt-4">
|
||||
<Button className="flex-1" onClick={() => setIsAddUserOpen(false)}>
|
||||
추가
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" onClick={() => setIsAddUserOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
내보내기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 통계 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">전체 사용자</p>
|
||||
<p className="text-2xl font-bold text-primary">{userStats.total}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">활성 사용자</p>
|
||||
<p className="text-2xl font-bold text-green-600">{userStats.active}</p>
|
||||
</div>
|
||||
<UserCheck className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">비활성 사용자</p>
|
||||
<p className="text-2xl font-bold text-gray-600">{userStats.inactive}</p>
|
||||
</div>
|
||||
<UserX className="h-8 w-8 text-gray-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">이번 달 신규</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{userStats.newThisMonth}</p>
|
||||
</div>
|
||||
<UserPlus className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="samsung-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-64">
|
||||
<Label htmlFor="search" className="text-sm font-medium">검색</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="이름, 이메일, 부서로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Label className="text-sm font-medium">부서</Label>
|
||||
<Select value={selectedDepartment} onValueChange={setSelectedDepartment}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{departments.slice(1).map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Label className="text-sm font-medium">역할</Label>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map(role => (
|
||||
<SelectItem key={role.value} value={role.value}>{role.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Label className="text-sm font-medium">상태</Label>
|
||||
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="활성">활성</SelectItem>
|
||||
<SelectItem value="비활성">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" className="px-3">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
<Card className="samsung-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>사용자 목록 ({filteredUsers.length}명)</span>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>필터링된 결과</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>사용자</TableHead>
|
||||
<TableHead>연락처</TableHead>
|
||||
<TableHead>부서/직급</TableHead>
|
||||
<TableHead>역할</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>마지막 로그인</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-primary/20 to-primary/40 rounded-full flex items-center justify-center">
|
||||
<span className="text-primary font-semibold text-sm">
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center text-sm">
|
||||
<Mail className="w-3 h-3 mr-1 text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<Phone className="w-3 h-3 mr-1 text-muted-foreground" />
|
||||
{user.phone}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center text-sm">
|
||||
<Building className="w-3 h-3 mr-1 text-muted-foreground" />
|
||||
{user.department}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{user.position}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getRoleBadge(user.role)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(user.status)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-sm">
|
||||
<Calendar className="w-3 h-3 mr-1 text-muted-foreground" />
|
||||
{user.lastLogin}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
<Shield className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
{user.status === "활성" ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="px-2 text-red-600 hover:text-red-700">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCurrentTime } from "@/hooks/useCurrentTime";
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Shield,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Factory,
|
||||
Activity,
|
||||
FileText,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
|
||||
export function WorkerDashboard() {
|
||||
const currentTime = useCurrentTime();
|
||||
|
||||
const workerData = useMemo(() => {
|
||||
return {
|
||||
myTasks: [
|
||||
{ id: "W001", product: "스마트폰 케이스", quantity: 150, deadline: "14:00", status: "진행중" },
|
||||
{ id: "W002", product: "태블릿 스탠드", quantity: 80, deadline: "16:30", status: "대기" }
|
||||
],
|
||||
currentShift: "1교대",
|
||||
workTime: "08:00-17:00",
|
||||
todayProduction: 120,
|
||||
targetProduction: 150,
|
||||
safetyAlerts: 0,
|
||||
equipment: {
|
||||
machine1: "정상",
|
||||
machine2: "점검필요"
|
||||
},
|
||||
qualityChecks: 12
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4 md:space-y-6">
|
||||
{/* 작업자 헤더 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground">작업자 대시보드</h1>
|
||||
<p className="text-muted-foreground mt-1">{workerData.currentShift} · {workerData.workTime} · {currentTime}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
안전점검
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" size="sm">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
품질확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개인 실적 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">금일 생산</CardTitle>
|
||||
<Factory className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{workerData.todayProduction}개
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
목표: {workerData.targetProduction}개 ({Math.round((workerData.todayProduction / workerData.targetProduction) * 100)}%)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">품질 검사</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{workerData.qualityChecks}회
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
불량률: 0%
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">안전 상태</CardTitle>
|
||||
<Shield className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
안전
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
경고: {workerData.safetyAlerts}건
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">작업 진행률</CardTitle>
|
||||
<Activity className="h-4 w-4 text-purple-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{Math.round((workerData.todayProduction / workerData.targetProduction) * 100)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
잔여: {workerData.targetProduction - workerData.todayProduction}개
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 개인 작업 현황 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>배정된 작업</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{workerData.myTasks.map((task, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-3 bg-muted/50 dark:bg-muted/20 rounded">
|
||||
<div>
|
||||
<p className="font-medium">{task.product}</p>
|
||||
<p className="text-sm text-muted-foreground">수량: {task.quantity}개 | 마감: {task.deadline}</p>
|
||||
</div>
|
||||
<Badge className={task.status === "진행중" ? "bg-blue-500 text-white" : "bg-muted text-muted-foreground"}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>설비 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center p-3 bg-green-50 rounded">
|
||||
<span className="font-medium">가공기 #1</span>
|
||||
<Badge className="bg-green-500 text-white">{workerData.equipment.machine1}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-yellow-50 rounded">
|
||||
<span className="font-medium">검사기 #2</span>
|
||||
<Badge className="bg-yellow-500 text-white">{workerData.equipment.machine2}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ClipboardList,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Package,
|
||||
Calendar,
|
||||
User
|
||||
} from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
export function WorkerPerformance() {
|
||||
const [workStatus, setWorkStatus] = useState<"idle" | "working" | "paused">("idle");
|
||||
const [currentTime, setCurrentTime] = useState("00:00:00");
|
||||
|
||||
// 금일 작업 지시 데이터
|
||||
const todayTasks = [
|
||||
{
|
||||
id: "WO-2024-001",
|
||||
product: "방화셔터 3000×3000",
|
||||
quantity: 2,
|
||||
priority: "긴급",
|
||||
deadline: "14:00",
|
||||
status: "진행중",
|
||||
progress: 60,
|
||||
startTime: "09:00"
|
||||
},
|
||||
{
|
||||
id: "WO-2024-002",
|
||||
product: "일반셔터 2500×2500",
|
||||
quantity: 3,
|
||||
priority: "보통",
|
||||
deadline: "17:00",
|
||||
status: "대기",
|
||||
progress: 0,
|
||||
startTime: null
|
||||
},
|
||||
{
|
||||
id: "WO-2024-003",
|
||||
product: "특수셔터 4000×3500",
|
||||
quantity: 1,
|
||||
priority: "긴급",
|
||||
deadline: "16:00",
|
||||
status: "대기",
|
||||
progress: 0,
|
||||
startTime: null
|
||||
}
|
||||
];
|
||||
|
||||
// 완료 작업 데이터
|
||||
const completedTasks = [
|
||||
{
|
||||
id: "WO-2024-000",
|
||||
product: "방화셔터 2800×2800",
|
||||
quantity: 2,
|
||||
completedTime: "08:45",
|
||||
qualityCheck: "합격"
|
||||
}
|
||||
];
|
||||
|
||||
const handleStartWork = (taskId: string) => {
|
||||
setWorkStatus("working");
|
||||
console.log("작업 시작:", taskId);
|
||||
};
|
||||
|
||||
const handlePauseWork = () => {
|
||||
setWorkStatus("paused");
|
||||
};
|
||||
|
||||
const handleCompleteWork = (taskId: string) => {
|
||||
setWorkStatus("idle");
|
||||
console.log("작업 완료:", taskId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-card border border-border/20 rounded-xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">작업 실적 관리</h1>
|
||||
<p className="text-muted-foreground">금일 작업 지시 및 실적 입력</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">작업자</p>
|
||||
<p className="font-bold text-foreground">박작업</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업 현황 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">금일 지시</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-primary">{todayTasks.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">건</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">진행중</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{todayTasks.filter(t => t.status === "진행중").length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">건</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">완료</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">{completedTasks.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">건</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">목표 달성률</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-600">75%</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">진행중</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 금일 작업 지시 */}
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<ClipboardList className="h-6 w-6 text-primary" />
|
||||
<span>금일 작업 지시</span>
|
||||
<Badge className="bg-blue-500 text-white">{todayTasks.length}건</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{todayTasks.map((task, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
task.status === "진행중"
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
|
||||
: "border-border/50 bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Badge
|
||||
className={`${
|
||||
task.priority === "긴급"
|
||||
? "bg-red-500"
|
||||
: task.priority === "높음"
|
||||
? "bg-orange-500"
|
||||
: "bg-blue-500"
|
||||
} text-white`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
<span className="font-bold text-foreground">{task.id}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
task.status === "진행중"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-gray-500 text-gray-600"
|
||||
}
|
||||
>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-foreground mb-1">{task.product}</h3>
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Package className="h-4 w-4" />
|
||||
<span>수량: {task.quantity}개</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>마감: {task.deadline}</span>
|
||||
</div>
|
||||
{task.startTime && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Play className="h-4 w-4" />
|
||||
<span>시작: {task.startTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{task.status === "진행중" && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-muted-foreground">진행률</span>
|
||||
<span className="font-bold text-blue-600">{task.progress}%</span>
|
||||
</div>
|
||||
<Progress value={task.progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2 md:w-48">
|
||||
{task.status === "대기" && (
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={() => handleStartWork(task.id)}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
작업 시작
|
||||
</Button>
|
||||
)}
|
||||
{task.status === "진행중" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-orange-500 text-orange-600"
|
||||
onClick={handlePauseWork}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
일시정지
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={() => handleCompleteWork(task.id)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
작업 완료
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 금일 완료 작업 */}
|
||||
<Card className="border border-border/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||
<span>금일 완료 작업</span>
|
||||
<Badge className="bg-green-500 text-white">{completedTasks.length}건</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{completedTasks.map((task, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 bg-green-50 dark:bg-green-950/20 rounded-xl border border-green-200 dark:border-green-800"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="font-bold text-foreground">{task.id}</span>
|
||||
<Badge className="bg-green-600 text-white">완료</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-600 text-green-600"
|
||||
>
|
||||
{task.qualityCheck}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground">{task.product}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
수량: {task.quantity}개 · 완료시간: {task.completedTime}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,17 +21,26 @@ export function useAuthGuard() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🔄 useAuthGuard: Starting auth check...');
|
||||
|
||||
// 페이지 로드 시 인증 확인
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
console.log('📡 Fetching /api/auth/check...');
|
||||
// 🔵 Next.js 내부 API - 쿠키에서 토큰 확인 (PHP 호출 X, 성능 최적화)
|
||||
const response = await fetch('/api/auth/check', {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
console.log('📥 Response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
// 인증 실패 시 로그인 페이지로 이동
|
||||
console.log('⚠️ 인증 실패: 로그인 페이지로 이동');
|
||||
router.replace('/login');
|
||||
} else {
|
||||
console.log('✅ 인증 성공');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 인증 확인 오류:', error);
|
||||
@@ -56,5 +65,5 @@ export function useAuthGuard() {
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
};
|
||||
}, [router]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
// 서버에서 받은 사용자 정보로 초기화
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated) return;
|
||||
// ⚠️ Allow rendering even before hydration (Zustand persist rehydration can be slow)
|
||||
// Commenting out the hydration check prevents infinite loading spinner
|
||||
// if (!_hasHydrated) return;
|
||||
|
||||
// localStorage에서 사용자 정보 가져오기
|
||||
const userDataStr = localStorage.getItem("user");
|
||||
@@ -151,17 +153,11 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// hydration 완료 및 menuItems 설정 대기
|
||||
if (!_hasHydrated || menuItems.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ⚠️ FIXED: Removed hydration check to prevent infinite loading spinner
|
||||
// The hydration check was causing the dashboard to show a loading spinner indefinitely
|
||||
// because Zustand persist rehydration was taking too long or not completing properly.
|
||||
// By removing this check, we allow the component to render immediately with default values
|
||||
// and update once hydration completes through the useEffect above.
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex w-full p-3 gap-3">
|
||||
@@ -232,7 +228,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* 테마 선택 */}
|
||||
<ThemeSelect />
|
||||
<ThemeSelect native={false} />
|
||||
|
||||
{/* 유저 프로필 */}
|
||||
<div className="flex items-center space-x-4 pl-6 border-l border-border/30">
|
||||
|
||||
@@ -38,6 +38,6 @@
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"src/components/business"
|
||||
"src/components/_unused"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user