251127:1700 Frontend Start Build

This commit is contained in:
admin
2025-11-27 17:08:49 +07:00
parent 6abb746e08
commit 4f3aa87a93
1795 changed files with 893474 additions and 10 deletions

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

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

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