Files
sam-react-prod/src/components/business/Board.tsx
byeongcheolryu a68a25b737 [feat]: 인증 및 UI/UX 개선 작업
주요 변경사항:
- 로그인/회원가입 페이지 인증 리다이렉트 로직 추가
- 로그인 상태에서 auth 페이지 접근 시 대시보드로 자동 리다이렉트
- router.replace() 사용으로 브라우저 히스토리에서 auth 페이지 제거
- 사이드바 메뉴 활성화 동기화 개선 (URL 직접 입력 및 뒤로가기 대응)
- usePathname 기반 자동 메뉴 활성화 로직 추가
- ESLint 설정 업데이트 (전역 변수 추가, business 폴더 제외)
- TypeScript 빌드 설정 조정 (ignoreBuildErrors 추가)
- 다국어 지원 및 테마 선택 기능 통합
- 대시보드 레이아웃 및 컴포넌트 구조 개선
- UI 컴포넌트 라이브러리 확장 (dialog, sheet, progress 등)

기술적 개선:
- HttpOnly 쿠키 기반 인증 시스템 유지
- 로딩 상태 UI 추가 (인증 체크 중)
- 경로 정규화 로직 (locale 제거)
- 재귀적 메뉴 탐색 및 자동 확장

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 18:55:16 +09:00

656 lines
29 KiB
TypeScript

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