251127:1700 Frontend Start Build
This commit is contained in:
203
frontend/components/custom/file-upload-zone.tsx
Normal file
203
frontend/components/custom/file-upload-zone.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
// File: components/custom/file-upload-zone.tsx
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { UploadCloud, File, X, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export interface FileWithMeta extends File {
|
||||
preview?: string;
|
||||
validationError?: string;
|
||||
}
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
/** Callback เมื่อไฟล์มีการเปลี่ยนแปลง */
|
||||
onFilesChanged: (files: FileWithMeta[]) => void;
|
||||
/** ประเภทไฟล์ที่ยอมรับ (เช่น .pdf, .dwg) */
|
||||
accept?: string[];
|
||||
/** ขนาดไฟล์สูงสุด (Bytes) Default: 50MB */
|
||||
maxSize?: number;
|
||||
/** อนุญาตให้อัปโหลดหลายไฟล์หรือไม่ */
|
||||
multiple?: boolean;
|
||||
/** ไฟล์ที่มีอยู่เดิม (ถ้ามี) */
|
||||
initialFiles?: FileWithMeta[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: แปลง Bytes เป็นหน่วยที่อ่านง่าย
|
||||
*/
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (!+bytes) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* FileUploadZone Component
|
||||
* รองรับ Drag & Drop, Validation และแสดงรายการไฟล์
|
||||
*/
|
||||
export function FileUploadZone({
|
||||
onFilesChanged,
|
||||
accept = [".pdf", ".dwg", ".docx", ".xlsx", ".zip"],
|
||||
maxSize = 50 * 1024 * 1024, // 50MB Default
|
||||
multiple = true,
|
||||
initialFiles = [],
|
||||
className,
|
||||
}: FileUploadZoneProps) {
|
||||
const [files, setFiles] = useState<FileWithMeta[]>(initialFiles);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// ตรวจสอบไฟล์
|
||||
const validateFile = (file: File): string | undefined => {
|
||||
// 1. Check Size
|
||||
if (file.size > maxSize) {
|
||||
return `ขนาดไฟล์เกินกำหนด (${formatBytes(maxSize)})`;
|
||||
}
|
||||
// 2. Check Type (Extension based validation for simplicity on client)
|
||||
const fileExtension = "." + file.name.split(".").pop()?.toLowerCase();
|
||||
if (accept.length > 0 && !accept.includes(fileExtension)) {
|
||||
return `ประเภทไฟล์ไม่รองรับ (อนุญาต: ${accept.join(", ")})`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
const processedFiles: FileWithMeta[] = newFiles.map((file) => {
|
||||
const error = validateFile(file);
|
||||
// สร้าง Object ใหม่เพื่อไม่ให้กระทบ File object เดิม
|
||||
const fileWithMeta = new File([file], file.name, { type: file.type }) as FileWithMeta;
|
||||
fileWithMeta.validationError = error;
|
||||
return fileWithMeta;
|
||||
});
|
||||
|
||||
setFiles((prev) => {
|
||||
const updated = multiple ? [...prev, ...processedFiles] : processedFiles;
|
||||
onFilesChanged(updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[maxSize, accept, multiple, onFilesChanged]
|
||||
);
|
||||
|
||||
// Drag Events
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const onDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleFileSelect(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (indexToRemove: number) => {
|
||||
setFiles((prev) => {
|
||||
const updated = prev.filter((_, index) => index !== indexToRemove);
|
||||
onFilesChanged(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-4", className)}>
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer flex flex-col items-center justify-center gap-2",
|
||||
isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/25 hover:border-primary/50",
|
||||
"h-48"
|
||||
)}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={() => document.getElementById("file-input")?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple={multiple}
|
||||
accept={accept.join(",")}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) handleFileSelect(Array.from(e.target.files));
|
||||
}}
|
||||
/>
|
||||
<div className="p-3 bg-muted rounded-full">
|
||||
<UploadCloud className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
คลิกเพื่อเลือกไฟล์ หรือ ลากไฟล์มาวางที่นี่
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
รองรับ: {accept.join(", ")} (สูงสุด {formatBytes(maxSize)})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<ScrollArea className="h-[200px] w-full rounded-md border p-4">
|
||||
<div className="space-y-3">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={`${file.name}-${index}`}
|
||||
className="flex items-center justify-between p-3 bg-card rounded-md border shadow-sm group"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="p-2 bg-primary/10 rounded-md shrink-0">
|
||||
<File className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate max-w-[200px] sm:max-w-md">
|
||||
{file.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
{file.validationError ? (
|
||||
<Badge variant="destructive" className="text-[10px] px-1 h-5 flex gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> {file.validationError}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] px-1 h-5 text-green-600 bg-green-50 border-green-200 flex gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> Ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFile(index)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
frontend/components/custom/responsive-data-table.tsx
Normal file
125
frontend/components/custom/responsive-data-table.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
// File: components/custom/responsive-data-table.tsx
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Interface สำหรับ Column Definition
|
||||
*/
|
||||
export interface ColumnDef<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
/** ฟังก์ชันสำหรับ render cell content (optional) */
|
||||
cell?: (item: T) => React.ReactNode;
|
||||
/** คลาส CSS เพิ่มเติมสำหรับ cell */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props สำหรับ ResponsiveDataTable
|
||||
*/
|
||||
interface ResponsiveDataTableProps<T> {
|
||||
/** ข้อมูลที่จะแสดงในตาราง */
|
||||
data: T[];
|
||||
/** นิยามของคอลัมน์ */
|
||||
columns: ColumnDef<T>[];
|
||||
/** Key ที่เป็น Unique ID ของข้อมูล (เช่น 'id', 'user_id') */
|
||||
keyExtractor: (item: T) => string | number;
|
||||
/** ฟังก์ชันสำหรับ Render Card View บน Mobile (ถ้าไม่ใส่จะ Render แบบ Default Key-Value) */
|
||||
renderMobileCard?: (item: T) => React.ReactNode;
|
||||
/** ข้อความเมื่อไม่มีข้อมูล */
|
||||
emptyMessage?: string;
|
||||
/** คลาส CSS เพิ่มเติมสำหรับ Container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResponsiveDataTable Component
|
||||
* * แสดงผลเป็น Table ปกติในหน้าจอขนาด md ขึ้นไป
|
||||
* และแสดงผลเป็น Card List ในหน้าจอขนาดเล็กกว่า md
|
||||
*/
|
||||
export function ResponsiveDataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
renderMobileCard,
|
||||
emptyMessage = "ไม่พบข้อมูล",
|
||||
className,
|
||||
}: ResponsiveDataTableProps<T>) {
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground border rounded-md bg-background">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-4", className)}>
|
||||
{/* --- Desktop View (Table) --- */}
|
||||
<div className="hidden md:block rounded-md border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHead key={col.key} className={col.className}>
|
||||
{col.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<TableRow key={keyExtractor(item)}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={`${keyExtractor(item)}-${col.key}`} className={col.className}>
|
||||
{col.cell ? col.cell(item) : (item as any)[col.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* --- Mobile View (Cards) --- */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.map((item) => (
|
||||
<div key={keyExtractor(item)}>
|
||||
{renderMobileCard ? (
|
||||
// Custom Mobile Render
|
||||
renderMobileCard(item)
|
||||
) : (
|
||||
// Default Mobile Render (Key-Value Pairs)
|
||||
<Card>
|
||||
<CardHeader className="pb-2 font-semibold border-b mb-2">
|
||||
# {keyExtractor(item)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="flex justify-between items-start border-b pb-1 last:border-0">
|
||||
<span className="font-medium text-muted-foreground w-1/3">{col.header}:</span>
|
||||
<span className="text-right w-2/3 break-words">
|
||||
{col.cell ? col.cell(item) : (item as any)[col.key]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/components/custom/workflow-visualizer.tsx
Normal file
111
frontend/components/custom/workflow-visualizer.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// File: components/custom/workflow-visualizer.tsx
|
||||
|
||||
import React from "react";
|
||||
import { Check, Clock, XCircle, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* สถานะของขั้นตอนใน Workflow
|
||||
*/
|
||||
export type StepStatus = "completed" | "current" | "pending" | "rejected" | "skipped";
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: string | number;
|
||||
label: string;
|
||||
subLabel?: string; // เช่น ชื่อองค์กร หรือ ชื่อผู้อนุมัติ
|
||||
status: StepStatus;
|
||||
date?: string; // วันที่ดำเนินการ (ถ้ามี)
|
||||
}
|
||||
|
||||
interface WorkflowVisualizerProps {
|
||||
steps: WorkflowStep[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkflowVisualizer Component
|
||||
* แสดงเส้นเวลา (Timeline) ของกระบวนการอนุมัติแบบแนวนอน
|
||||
*/
|
||||
export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps) {
|
||||
return (
|
||||
<div className={cn("w-full overflow-x-auto py-4 px-2", className)}>
|
||||
<div className="flex items-start min-w-max">
|
||||
{steps.map((step, index) => {
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
// กำหนดสีตามสถานะ
|
||||
let statusColor = "bg-muted text-muted-foreground border-muted"; // pending
|
||||
let icon = <span className="text-xs">{index + 1}</span>;
|
||||
let lineColor = "bg-muted";
|
||||
|
||||
switch (step.status) {
|
||||
case "completed":
|
||||
statusColor = "bg-green-600 text-white border-green-600";
|
||||
icon = <Check className="w-4 h-4" />;
|
||||
lineColor = "bg-green-600";
|
||||
break;
|
||||
case "current":
|
||||
statusColor = "bg-blue-600 text-white border-blue-600 ring-4 ring-blue-100";
|
||||
icon = <Clock className="w-4 h-4 animate-pulse" />;
|
||||
lineColor = "bg-muted"; // เส้นต่อไปยังเป็นสีเทา
|
||||
break;
|
||||
case "rejected":
|
||||
statusColor = "bg-destructive text-destructive-foreground border-destructive";
|
||||
icon = <XCircle className="w-4 h-4" />;
|
||||
lineColor = "bg-destructive";
|
||||
break;
|
||||
case "skipped":
|
||||
statusColor = "bg-orange-400 text-white border-orange-400";
|
||||
icon = <AlertCircle className="w-4 h-4" />;
|
||||
lineColor = "bg-orange-400";
|
||||
break;
|
||||
case "pending":
|
||||
default:
|
||||
// ใช้ default
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex flex-col items-center flex-1 group">
|
||||
{/* Connector Line (Left & Right) */}
|
||||
<div className="flex items-center w-full absolute top-4 left-0 -z-10">
|
||||
{/* Left Half Line (Previous step connection) */}
|
||||
<div className={cn("h-1 w-1/2", index === 0 ? "bg-transparent" : (steps[index-1].status === 'completed' || steps[index-1].status === 'skipped' ? lineColor : (steps[index].status === 'completed' ? lineColor : 'bg-muted')))} />
|
||||
|
||||
{/* Right Half Line (Next step connection) */}
|
||||
<div className={cn("h-1 w-1/2", isLast ? "bg-transparent" : (step.status === 'completed' || step.status === 'skipped' ? lineColor : 'bg-muted'))} />
|
||||
</div>
|
||||
|
||||
{/* Step Circle */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300",
|
||||
statusColor
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Step Label */}
|
||||
<div className="mt-3 text-center space-y-1 max-w-[120px]">
|
||||
<p className={cn("text-sm font-semibold", step.status === 'current' ? 'text-blue-700' : 'text-foreground')}>
|
||||
{step.label}
|
||||
</p>
|
||||
{step.subLabel && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={step.subLabel}>
|
||||
{step.subLabel}
|
||||
</p>
|
||||
)}
|
||||
{step.date && (
|
||||
<p className="text-[10px] text-muted-foreground bg-secondary px-1.5 py-0.5 rounded-full inline-block">
|
||||
{step.date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user