Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into dataflowMng
This commit is contained in:
@@ -5,6 +5,7 @@ import { CompanyToolbar } from "./CompanyToolbar";
|
||||
import { CompanyTable } from "./CompanyTable";
|
||||
import { CompanyFormModal } from "./CompanyFormModal";
|
||||
import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
|
||||
import { DiskUsageSummary } from "./DiskUsageSummary";
|
||||
|
||||
/**
|
||||
* 회사 관리 메인 컴포넌트
|
||||
@@ -18,6 +19,11 @@ export function CompanyManagement() {
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// 디스크 사용량 관련
|
||||
diskUsageInfo,
|
||||
isDiskUsageLoading,
|
||||
loadDiskUsage,
|
||||
|
||||
// 모달 상태
|
||||
modalState,
|
||||
deleteState,
|
||||
@@ -46,6 +52,9 @@ export function CompanyManagement() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 디스크 사용량 요약 */}
|
||||
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
|
||||
|
||||
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
||||
<CompanyToolbar
|
||||
searchFilter={searchFilter}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { Edit, Trash2, HardDrive, FileText } from "lucide-react";
|
||||
import { Company } from "@/types/company";
|
||||
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface CompanyTableProps {
|
||||
companies: Company[];
|
||||
@@ -15,6 +16,32 @@ interface CompanyTableProps {
|
||||
* 회사 목록 테이블 컴포넌트
|
||||
*/
|
||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
||||
// 디스크 사용량 포맷팅 함수
|
||||
const formatDiskUsage = (company: Company) => {
|
||||
if (!company.diskUsage) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span className="text-xs">정보 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { fileCount, totalSizeMB } = company.diskUsage;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-xs font-medium">{fileCount}개 파일</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-green-500" />
|
||||
<span className="text-xs">{totalSizeMB.toFixed(1)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// 상태에 따른 Badge 색상 결정
|
||||
console.log(companies);
|
||||
// 로딩 상태 렌더링
|
||||
@@ -94,6 +121,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[140px]">디스크 사용량</TableHead>
|
||||
<TableHead className="w-[120px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -103,6 +131,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||
<TableCell className="font-mono text-sm">{company.company_code}</TableCell>
|
||||
<TableCell className="font-medium">{company.company_name}</TableCell>
|
||||
<TableCell>{company.writer}</TableCell>
|
||||
<TableCell className="py-2">{formatDiskUsage(company)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
138
frontend/components/admin/DiskUsageSummary.tsx
Normal file
138
frontend/components/admin/DiskUsageSummary.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { RefreshCw, HardDrive, FileText, Building2, Clock } from "lucide-react";
|
||||
import { AllDiskUsageInfo } from "@/types/company";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface DiskUsageSummaryProps {
|
||||
diskUsageInfo: AllDiskUsageInfo | null;
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크 사용량 요약 정보 컴포넌트
|
||||
*/
|
||||
export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) {
|
||||
if (!diskUsageInfo) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">디스크 사용량</CardTitle>
|
||||
<CardDescription>전체 회사 파일 저장 현황</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground flex items-center justify-center py-6">
|
||||
<div className="text-center">
|
||||
<HardDrive className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">디스크 사용량 정보를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, lastChecked } = diskUsageInfo;
|
||||
const lastCheckedDate = new Date(lastChecked);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">디스크 사용량 현황</CardTitle>
|
||||
<CardDescription>전체 회사 파일 저장 통계</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{/* 총 회사 수 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">총 회사</p>
|
||||
<p className="text-lg font-semibold">{summary.totalCompanies}개</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 파일 수 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">총 파일</p>
|
||||
<p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}개</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 용량 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<HardDrive className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">총 용량</p>
|
||||
<p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 마지막 업데이트 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">마지막 확인</p>
|
||||
<p className="text-xs font-medium">
|
||||
{lastCheckedDate.toLocaleString("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 용량 기준 상태 표시 */}
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">저장소 상태</span>
|
||||
<Badge
|
||||
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
|
||||
>
|
||||
{summary.totalSizeMB > 1000 ? "용량 주의" : summary.totalSizeMB > 500 ? "보통" : "여유"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 간단한 진행 바 */}
|
||||
<div className="mt-2 h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
summary.totalSizeMB > 1000 ? "bg-red-500" : summary.totalSizeMB > 500 ? "bg-yellow-500" : "bg-green-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min((summary.totalSizeMB / 2000) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex justify-between text-xs">
|
||||
<span>0 MB</span>
|
||||
<span>2,000 MB (권장 최대)</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
@@ -84,6 +87,49 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
const [editFormData, setEditFormData] = useState<Record<string, any>>({});
|
||||
const [editingRowData, setEditingRowData] = useState<Record<string, any> | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// 이미지 미리보기 상태
|
||||
const [previewImage, setPreviewImage] = useState<FileInfo | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
// 이미지 미리보기 핸들러들
|
||||
const handlePreviewImage = useCallback((fileInfo: FileInfo) => {
|
||||
setPreviewImage(fileInfo);
|
||||
setShowPreviewModal(true);
|
||||
setZoom(1);
|
||||
setRotation(0);
|
||||
}, []);
|
||||
|
||||
const closePreviewModal = useCallback(() => {
|
||||
setShowPreviewModal(false);
|
||||
setPreviewImage(null);
|
||||
setZoom(1);
|
||||
setRotation(0);
|
||||
}, []);
|
||||
|
||||
const handleZoom = useCallback((direction: "in" | "out") => {
|
||||
setZoom((prev) => {
|
||||
if (direction === "in") {
|
||||
return Math.min(prev + 0.25, 3);
|
||||
} else {
|
||||
return Math.max(prev - 0.25, 0.25);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
setRotation((prev) => (prev + 90) % 360);
|
||||
}, []);
|
||||
|
||||
const formatFileSize = useCallback((bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}, []);
|
||||
const [showFileModal, setShowFileModal] = useState(false);
|
||||
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
||||
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
||||
@@ -1812,10 +1858,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// TODO: 이미지 미리보기 모달 구현
|
||||
alert("이미지 미리보기 기능은 준비 중입니다.");
|
||||
}}
|
||||
onClick={() => handlePreviewImage(fileInfo)}
|
||||
>
|
||||
<Eye className="mr-1 h-4 w-4" />
|
||||
미리보기
|
||||
@@ -1904,6 +1947,65 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 이미지 미리보기 다이얼로그 */}
|
||||
<Dialog open={showPreviewModal} onOpenChange={closePreviewModal}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span className="truncate">{previewImage?.name}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => handleZoom("out")} disabled={zoom <= 0.25}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="min-w-[60px] text-center text-sm text-gray-500">{Math.round(zoom * 100)}%</span>
|
||||
<Button size="sm" variant="outline" onClick={() => handleZoom("in")} disabled={zoom >= 3}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleRotate}>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
{previewImage && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleDownloadFile(previewImage);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
||||
{previewImage && (
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.serverFilename}`}
|
||||
alt={previewImage.name}
|
||||
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||
}}
|
||||
onError={() => {
|
||||
console.error("이미지 로딩 실패:", previewImage);
|
||||
toast.error("이미지를 불러올 수 없습니다.");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewImage && (
|
||||
<div className="flex items-center justify-between border-t pt-3 text-sm text-gray-500">
|
||||
<div>크기: {formatFileSize(previewImage.size)}</div>
|
||||
<div>타입: {previewImage.type}</div>
|
||||
<div>업로드: {new Date(previewImage.uploadedAt).toLocaleDateString("ko-KR")}</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -826,6 +826,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
업로드된 파일 ({fileData.length}개)
|
||||
</div>
|
||||
{fileData.map((fileInfo: any, index: number) => {
|
||||
const isImage = fileInfo.type?.startsWith('image/');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user