251205:0000 Just start debug backend/frontend
This commit is contained in:
48
frontend/components/admin/sidebar.tsx
Normal file
48
frontend/components/admin/sidebar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Users, Building2, Settings, FileText, Activity, Network, Hash } from "lucide-react";
|
||||
|
||||
const menuItems = [
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/workflows", label: "Workflows", icon: Network },
|
||||
{ href: "/admin/numbering", label: "Numbering", icon: Hash },
|
||||
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
|
||||
{ href: "/admin/projects", label: "Projects", icon: FileText },
|
||||
{ href: "/admin/settings", label: "Settings", icon: Settings },
|
||||
{ href: "/admin/audit-logs", label: "Audit Logs", icon: Activity },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="w-64 border-r bg-muted/20 p-4 hidden md:block h-full">
|
||||
<h2 className="text-lg font-bold mb-6 px-3">Admin Panel</h2>
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
240
frontend/components/admin/user-dialog.tsx
Normal file
240
frontend/components/admin/user-dialog.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { User, CreateUserDto } from "@/types/admin";
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminApi } from "@/lib/api/admin";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const userSchema = z.object({
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters").optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
roles: z.array(z.number()).min(1, "At least one role is required"),
|
||||
});
|
||||
|
||||
type UserFormData = z.infer<typeof userSchema>;
|
||||
|
||||
interface UserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: User | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function UserDialog({ open, onOpenChange, user, onSuccess }: UserDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<UserFormData>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
is_active: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
reset({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
is_active: user.is_active,
|
||||
roles: user.roles.map((r) => r.role_id),
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
username: "",
|
||||
email: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
is_active: true,
|
||||
roles: [],
|
||||
});
|
||||
}
|
||||
}, [user, reset, open]);
|
||||
|
||||
const availableRoles = [
|
||||
{ role_id: 1, role_name: "ADMIN", description: "System Administrator" },
|
||||
{ role_id: 2, role_name: "USER", description: "Regular User" },
|
||||
{ role_id: 3, role_name: "APPROVER", description: "Document Approver" },
|
||||
];
|
||||
|
||||
const selectedRoles = watch("roles") || [];
|
||||
|
||||
const handleRoleChange = (roleId: number, checked: boolean) => {
|
||||
const currentRoles = selectedRoles;
|
||||
const newRoles = checked
|
||||
? [...currentRoles, roleId]
|
||||
: currentRoles.filter((id) => id !== roleId);
|
||||
setValue("roles", newRoles, { shouldValidate: true });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: UserFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (user) {
|
||||
// await adminApi.updateUser(user.user_id, data);
|
||||
console.log("Update user", user.user_id, data);
|
||||
} else {
|
||||
await adminApi.createUser(data as CreateUserDto);
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to save user");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{user ? "Edit User" : "Create New User"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="username">Username *</Label>
|
||||
<Input id="username" {...register("username")} disabled={!!user} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input id="email" type="email" {...register("email")} />
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="first_name">First Name *</Label>
|
||||
<Input id="first_name" {...register("first_name")} />
|
||||
{errors.first_name && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.first_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="last_name">Last Name *</Label>
|
||||
<Input id="last_name" {...register("last_name")} />
|
||||
{errors.last_name && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.last_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
<div>
|
||||
<Label htmlFor="password">Password *</Label>
|
||||
<Input id="password" type="password" {...register("password")} />
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Roles *</Label>
|
||||
<div className="space-y-2 border rounded-md p-4">
|
||||
{availableRoles.map((role) => (
|
||||
<div
|
||||
key={role.role_id}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`role-${role.role_id}`}
|
||||
checked={selectedRoles.includes(role.role_id)}
|
||||
onCheckedChange={(checked) => handleRoleChange(role.role_id, checked as boolean)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor={`role-${role.role_id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{role.role_name}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{role.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{errors.roles && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.roles.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(checked) => setValue("is_active", checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="is_active">Active</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{user ? "Update User" : "Create User"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
0
frontend/components/common/can.tsx
Normal file
0
frontend/components/common/can.tsx
Normal file
49
frontend/components/common/confirm-dialog.tsx
Normal file
49
frontend/components/common/confirm-dialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
onConfirm: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
87
frontend/components/common/data-table.tsx
Normal file
87
frontend/components/common/data-table.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
frontend/components/common/file-upload.tsx
Normal file
102
frontend/components/common/file-upload.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Upload, X, File } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
maxFiles?: number;
|
||||
accept?: string;
|
||||
maxSize?: number; // bytes
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
onFilesSelected,
|
||||
maxFiles = 5,
|
||||
accept = ".pdf,.doc,.docx",
|
||||
maxSize = 10485760, // 10MB
|
||||
}: FileUploadProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
setFiles((prev) => {
|
||||
const newFiles = [...prev, ...acceptedFiles].slice(0, maxFiles);
|
||||
onFilesSelected(newFiles);
|
||||
return newFiles;
|
||||
});
|
||||
},
|
||||
[maxFiles, onFilesSelected]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxFiles,
|
||||
accept: accept.split(",").reduce((acc, ext) => ({ ...acc, [ext]: [] }), {}),
|
||||
maxSize,
|
||||
});
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => {
|
||||
const newFiles = prev.filter((_, i) => i !== index);
|
||||
onFilesSelected(newFiles);
|
||||
return newFiles;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
|
||||
isDragActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-300 hover:border-gray-400"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{isDragActive
|
||||
? "Drop files here"
|
||||
: "Drag & drop files or click to browse"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Maximum {maxFiles} files, {(maxSize / 1024 / 1024).toFixed(0)}MB each
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<File className="h-5 w-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFile(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/components/common/pagination.tsx
Normal file
74
frontend/components/common/pagination.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
}: PaginationProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const createPageURL = (pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing page {currentPage} of {totalPages} ({total} total items)
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(createPageURL(currentPage - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Simple pagination logic: show max 5 pages */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
// Logic to center current page could be added here for large page counts
|
||||
// For now, just showing first 5 or all if < 5
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => router.push(createPageURL(pageNum))}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(createPageURL(currentPage + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/components/common/status-badge.tsx
Normal file
63
frontend/components/common/status-badge.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: string }> = {
|
||||
DRAFT: { label: "Draft", variant: "secondary" },
|
||||
PENDING: { label: "Pending", variant: "warning" }, // Note: Shadcn/UI might not have 'warning' variant by default, may need custom CSS or use 'secondary'
|
||||
IN_REVIEW: { label: "In Review", variant: "default" }, // Using 'default' (primary) for In Review
|
||||
APPROVED: { label: "Approved", variant: "success" }, // Note: 'success' might need custom CSS
|
||||
REJECTED: { label: "Rejected", variant: "destructive" },
|
||||
CLOSED: { label: "Closed", variant: "outline" },
|
||||
};
|
||||
|
||||
// Fallback for unknown statuses
|
||||
const defaultStatus = { label: "Unknown", variant: "outline" };
|
||||
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
const config = statusConfig[status] || { label: status, variant: "default" };
|
||||
|
||||
// Mapping custom variants to Shadcn Badge variants if needed
|
||||
// For now, we'll assume standard variants or rely on className overrides for colors
|
||||
let badgeVariant: "default" | "secondary" | "destructive" | "outline" = "default";
|
||||
let customClass = "";
|
||||
|
||||
switch (config.variant) {
|
||||
case "secondary":
|
||||
badgeVariant = "secondary";
|
||||
break;
|
||||
case "destructive":
|
||||
badgeVariant = "destructive";
|
||||
break;
|
||||
case "outline":
|
||||
badgeVariant = "outline";
|
||||
break;
|
||||
case "warning":
|
||||
badgeVariant = "secondary"; // Fallback
|
||||
customClass = "bg-yellow-500 hover:bg-yellow-600 text-white";
|
||||
break;
|
||||
case "success":
|
||||
badgeVariant = "default"; // Fallback
|
||||
customClass = "bg-green-500 hover:bg-green-600 text-white";
|
||||
break;
|
||||
case "info":
|
||||
badgeVariant = "default";
|
||||
customClass = "bg-blue-500 hover:bg-blue-600 text-white";
|
||||
break;
|
||||
default:
|
||||
badgeVariant = "default";
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={badgeVariant}
|
||||
className={cn("uppercase", customClass, className)}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
133
frontend/components/correspondences/detail.tsx
Normal file
133
frontend/components/correspondences/detail.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { Correspondence } from "@/types/correspondence";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, Download, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface CorrespondenceDetailProps {
|
||||
data: Correspondence;
|
||||
}
|
||||
|
||||
export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/correspondences">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{data.document_number}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Created on {format(new Date(data.created_at), "dd MMM yyyy HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Workflow Actions Placeholder */}
|
||||
{data.status === "DRAFT" && (
|
||||
<Button>Submit for Review</Button>
|
||||
)}
|
||||
{data.status === "IN_REVIEW" && (
|
||||
<>
|
||||
<Button variant="destructive">Reject</Button>
|
||||
<Button className="bg-green-600 hover:bg-green-700">Approve</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl">{data.subject}</CardTitle>
|
||||
<StatusBadge status={data.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Description</h3>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">
|
||||
{data.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Attachments</h3>
|
||||
{data.attachments && data.attachments.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{data.attachments.map((file: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 border rounded-lg bg-muted/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-medium">{file.name || `Attachment ${index + 1}`}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No attachments.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Info */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Importance</p>
|
||||
<div className="mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${data.importance === 'URGENT' ? 'bg-red-100 text-red-800' :
|
||||
data.importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-blue-100 text-blue-800'}`}>
|
||||
{data.importance}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">From Organization</p>
|
||||
<p className="font-medium mt-1">{data.from_organization?.org_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.from_organization?.org_code}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">To Organization</p>
|
||||
<p className="font-medium mt-1">{data.to_organization?.org_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{data.to_organization?.org_code}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
frontend/components/correspondences/form.tsx
Normal file
187
frontend/components/correspondences/form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FileUpload } from "@/components/common/file-upload";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { correspondenceApi } from "@/lib/api/correspondences";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const correspondenceSchema = z.object({
|
||||
subject: z.string().min(5, "Subject must be at least 5 characters"),
|
||||
description: z.string().optional(),
|
||||
document_type_id: z.number().default(1), // Default to General for now
|
||||
from_organization_id: z.number({ required_error: "Please select From Organization" }),
|
||||
to_organization_id: z.number({ required_error: "Please select To Organization" }),
|
||||
importance: z.enum(["NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
|
||||
attachments: z.array(z.instanceof(File)).optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof correspondenceSchema>;
|
||||
|
||||
export function CorrespondenceForm() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(correspondenceSchema),
|
||||
defaultValues: {
|
||||
importance: "NORMAL",
|
||||
document_type_id: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await correspondenceApi.create(data as any); // Type casting for mock
|
||||
router.push("/correspondences");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to create correspondence");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
||||
{/* Subject */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
|
||||
{errors.subject && (
|
||||
<p className="text-sm text-destructive">{errors.subject.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register("description")}
|
||||
rows={4}
|
||||
placeholder="Enter description details..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* From/To Organizations */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>From Organization *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("from_organization_id", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Organization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Mock Data - In real app, fetch from API */}
|
||||
<SelectItem value="1">Contractor A (CON-A)</SelectItem>
|
||||
<SelectItem value="2">Owner (OWN)</SelectItem>
|
||||
<SelectItem value="3">Consultant (CNS)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.from_organization_id && (
|
||||
<p className="text-sm text-destructive">{errors.from_organization_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>To Organization *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("to_organization_id", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Organization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Contractor A (CON-A)</SelectItem>
|
||||
<SelectItem value="2">Owner (OWN)</SelectItem>
|
||||
<SelectItem value="3">Consultant (CNS)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.to_organization_id && (
|
||||
<p className="text-sm text-destructive">{errors.to_organization_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Importance */}
|
||||
<div className="space-y-2">
|
||||
<Label>Importance</Label>
|
||||
<div className="flex gap-6 mt-2">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="NORMAL"
|
||||
{...register("importance")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>Normal</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="HIGH"
|
||||
{...register("importance")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>High</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="URGENT"
|
||||
{...register("importance")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>Urgent</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Attachments */}
|
||||
<div className="space-y-2">
|
||||
<Label>Attachments</Label>
|
||||
<FileUpload
|
||||
onFilesSelected={(files) => setValue("attachments", files)}
|
||||
maxFiles={10}
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-4 pt-6 border-t">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Correspondence
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
87
frontend/components/correspondences/list.tsx
Normal file
87
frontend/components/correspondences/list.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Correspondence } from "@/types/correspondence";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Edit } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface CorrespondenceListProps {
|
||||
data: {
|
||||
items: Correspondence[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||
const columns: ColumnDef<Correspondence>[] = [
|
||||
{
|
||||
accessorKey: "document_number",
|
||||
header: "Document No.",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("document_number")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
|
||||
{row.getValue("subject")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "from_organization.org_name",
|
||||
header: "From",
|
||||
},
|
||||
{
|
||||
accessorKey: "to_organization.org_name",
|
||||
header: "To",
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: "Date",
|
||||
cell: ({ row }) => format(new Date(row.getValue("created_at")), "dd MMM yyyy"),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/correspondences/${item.correspondence_id}`}>
|
||||
<Button variant="ghost" size="icon" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{item.status === "DRAFT" && (
|
||||
<Link href={`/correspondences/${item.correspondence_id}/edit`}>
|
||||
<Button variant="ghost" size="icon" title="Edit">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable columns={columns} data={data.items} />
|
||||
{/* Pagination component would go here, receiving props from data */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
frontend/components/dashboard/pending-tasks.tsx
Normal file
66
frontend/components/dashboard/pending-tasks.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { PendingTask } from "@/types/dashboard";
|
||||
import { AlertCircle, ArrowRight } from "lucide-react";
|
||||
|
||||
interface PendingTasksProps {
|
||||
tasks: PendingTask[];
|
||||
}
|
||||
|
||||
export function PendingTasks({ tasks }: PendingTasksProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
Pending Tasks
|
||||
{tasks.length > 0 && (
|
||||
<Badge variant="destructive" className="rounded-full h-5 w-5 p-0 flex items-center justify-center text-[10px]">
|
||||
{tasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No pending tasks. Good job!
|
||||
</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={task.url}
|
||||
className="block p-3 bg-muted/40 rounded-lg border hover:bg-muted/60 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<span className="text-sm font-medium group-hover:text-primary transition-colors">
|
||||
{task.title}
|
||||
</span>
|
||||
{task.daysOverdue > 0 ? (
|
||||
<Badge variant="destructive" className="text-[10px] h-5 px-1.5">
|
||||
{task.daysOverdue}d overdue
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1.5 bg-yellow-50 text-yellow-700 border-yellow-200">
|
||||
Due Soon
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">
|
||||
{task.description}
|
||||
</p>
|
||||
<div className="flex items-center text-xs text-primary font-medium">
|
||||
View Details <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
30
frontend/components/dashboard/quick-actions.tsx
Normal file
30
frontend/components/dashboard/quick-actions.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusCircle, Upload, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
<Link href="/rfas/new">
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
New RFA
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/correspondences/new">
|
||||
<Button variant="outline">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
New Correspondence
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/drawings/upload">
|
||||
<Button variant="outline">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload Drawing
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +1,55 @@
|
||||
// File: components/dashboard/recent-activity.tsx
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar";
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ActivityLog } from "@/types/dashboard";
|
||||
import Link from "next/link";
|
||||
|
||||
// Type จำลองตามโครงสร้าง v_audit_log_details
|
||||
type AuditLogItem = {
|
||||
audit_id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
created_at: string;
|
||||
avatar?: string;
|
||||
};
|
||||
interface RecentActivityProps {
|
||||
activities: ActivityLog[];
|
||||
}
|
||||
|
||||
// Mock Data
|
||||
const recentActivities: AuditLogItem[] = [
|
||||
{
|
||||
audit_id: 1,
|
||||
username: "Editor01",
|
||||
email: "editor01@example.com",
|
||||
action: "rfa.create",
|
||||
entity_type: "RFA",
|
||||
entity_id: "LCBP3-RFA-STR-001",
|
||||
created_at: "2025-11-26T09:00:00Z",
|
||||
},
|
||||
{
|
||||
audit_id: 2,
|
||||
username: "Superadmin",
|
||||
email: "admin@example.com",
|
||||
action: "user.create",
|
||||
entity_type: "User",
|
||||
entity_id: "new_user_01",
|
||||
created_at: "2025-11-26T10:30:00Z",
|
||||
},
|
||||
{
|
||||
audit_id: 3,
|
||||
username: "Viewer01",
|
||||
email: "viewer01@example.com",
|
||||
action: "document.view",
|
||||
entity_type: "Correspondence",
|
||||
entity_id: "LCBP3-LET-GEN-005",
|
||||
created_at: "2025-11-26T11:15:00Z",
|
||||
},
|
||||
{
|
||||
audit_id: 4,
|
||||
username: "Editor01",
|
||||
email: "editor01@example.com",
|
||||
action: "shop_drawing.upload",
|
||||
entity_type: "Shop Drawing",
|
||||
entity_id: "SHP-STR-COL-01",
|
||||
created_at: "2025-11-26T13:45:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
export function RecentActivity() {
|
||||
export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
return (
|
||||
<Card className="col-span-3 lg:col-span-1">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardTitle className="text-lg">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{recentActivities.map((item) => (
|
||||
<div key={item.audit_id} className="flex items-center">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={item.avatar} alt={item.username} />
|
||||
<AvatarFallback>{item.username[0] + item.username[1]}</AvatarFallback>
|
||||
<div className="space-y-6">
|
||||
{activities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex gap-4 pb-4 border-b last:border-0 last:pb-0"
|
||||
>
|
||||
<Avatar className="h-10 w-10 border">
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||
{activity.user.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{item.username}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatActionMessage(item)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{new Date(item.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{activity.user.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 font-normal">
|
||||
{activity.action}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(activity.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link href={activity.targetUrl} className="block group">
|
||||
<p className="text-sm text-foreground group-hover:text-primary transition-colors">
|
||||
{activity.description}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -90,19 +58,3 @@ export function RecentActivity() {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatActionMessage(item: AuditLogItem) {
|
||||
// Simple formatter for demo. In real app, use translation or mapping.
|
||||
switch (item.action) {
|
||||
case "rfa.create":
|
||||
return `Created RFA ${item.entity_id}`;
|
||||
case "user.create":
|
||||
return `Created new user ${item.entity_id}`;
|
||||
case "document.view":
|
||||
return `Viewed document ${item.entity_id}`;
|
||||
case "shop_drawing.upload":
|
||||
return `Uploaded drawing ${item.entity_id}`;
|
||||
default:
|
||||
return `Performed ${item.action}`;
|
||||
}
|
||||
}
|
||||
64
frontend/components/dashboard/stats-cards.tsx
Normal file
64
frontend/components/dashboard/stats-cards.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { FileText, Clipboard, CheckCircle, Clock } from "lucide-react";
|
||||
import { DashboardStats } from "@/types/dashboard";
|
||||
|
||||
interface StatsCardsProps {
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
export function StatsCards({ stats }: StatsCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: "Total Correspondences",
|
||||
value: stats.correspondences,
|
||||
icon: FileText,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
title: "Active RFAs",
|
||||
value: stats.rfas,
|
||||
icon: Clipboard,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-50",
|
||||
},
|
||||
{
|
||||
title: "Approved Documents",
|
||||
value: stats.approved,
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-50",
|
||||
},
|
||||
{
|
||||
title: "Pending Approvals",
|
||||
value: stats.pending,
|
||||
icon: Clock,
|
||||
color: "text-orange-600",
|
||||
bgColor: "bg-orange-50",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
|
||||
return (
|
||||
<Card key={card.title} className="p-6 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{card.title}</p>
|
||||
<p className="text-3xl font-bold mt-2">{card.value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${card.bgColor}`}>
|
||||
<Icon className={`h-6 w-6 ${card.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
frontend/components/drawings/card.tsx
Normal file
72
frontend/components/drawings/card.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { Drawing } from "@/types/drawing";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileText, Download, Eye, GitCompare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex gap-4">
|
||||
{/* Thumbnail Placeholder */}
|
||||
<div className="w-24 h-24 bg-muted rounded flex items-center justify-center shrink-0">
|
||||
<FileText className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold truncate" title={drawing.drawing_number}>
|
||||
{drawing.drawing_number}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground truncate" title={drawing.title}>
|
||||
{drawing.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{drawing.discipline?.discipline_code}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheet_number}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Rev:</span> {drawing.current_revision}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Date:</span>{" "}
|
||||
{format(new Date(drawing.issue_date), "dd/MM/yyyy")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Link href={`/drawings/${drawing.drawing_id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
{drawing.revision_count > 1 && (
|
||||
<Button variant="outline" size="sm">
|
||||
<GitCompare className="mr-2 h-4 w-4" />
|
||||
Compare
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
56
frontend/components/drawings/list.tsx
Normal file
56
frontend/components/drawings/list.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Drawing } from "@/types/drawing";
|
||||
import { DrawingCard } from "@/components/drawings/card";
|
||||
import { drawingApi } from "@/lib/api/drawings";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface DrawingListProps {
|
||||
type: "CONTRACT" | "SHOP";
|
||||
}
|
||||
|
||||
export function DrawingList({ type }: DrawingListProps) {
|
||||
const [drawings, setDrawings] = useState<Drawing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDrawings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await drawingApi.getAll({ type });
|
||||
setDrawings(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch drawings", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDrawings();
|
||||
}, [type]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (drawings.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
|
||||
No drawings found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{drawings.map((drawing) => (
|
||||
<DrawingCard key={drawing.drawing_id} drawing={drawing} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/components/drawings/revision-history.tsx
Normal file
50
frontend/components/drawings/revision-history.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { DrawingRevision } from "@/types/drawing";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] }) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Revision History</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{revisions.map((rev) => (
|
||||
<div
|
||||
key={rev.revision_id}
|
||||
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Badge variant={rev.is_current ? "default" : "outline"}>
|
||||
Rev. {rev.revision_number}
|
||||
</Badge>
|
||||
{rev.is_current && (
|
||||
<span className="text-xs text-green-600 font-medium flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
|
||||
CURRENT
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
{rev.revision_description || "No description"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{format(new Date(rev.revision_date), "dd MMM yyyy")} by{" "}
|
||||
{rev.revised_by_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" title="Download">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
167
frontend/components/drawings/upload-form.tsx
Normal file
167
frontend/components/drawings/upload-form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
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 { Card } from "@/components/ui/card";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { drawingApi } from "@/lib/api/drawings";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const drawingSchema = z.object({
|
||||
drawing_type: z.enum(["CONTRACT", "SHOP"], { required_error: "Type is required" }),
|
||||
drawing_number: z.string().min(1, "Drawing Number is required"),
|
||||
title: z.string().min(5, "Title must be at least 5 characters"),
|
||||
discipline_id: z.number({ required_error: "Discipline is required" }),
|
||||
sheet_number: z.string().min(1, "Sheet Number is required"),
|
||||
scale: z.string().optional(),
|
||||
file: z.instanceof(File, { message: "File is required" }),
|
||||
});
|
||||
|
||||
type DrawingFormData = z.infer<typeof drawingSchema>;
|
||||
|
||||
export function DrawingUploadForm() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<DrawingFormData>({
|
||||
resolver: zodResolver(drawingSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: DrawingFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await drawingApi.create(data as any);
|
||||
router.push("/drawings");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to upload drawing");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Drawing Type *</Label>
|
||||
<Select onValueChange={(v) => setValue("drawing_type", v as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CONTRACT">Contract Drawing</SelectItem>
|
||||
<SelectItem value="SHOP">Shop Drawing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.drawing_type && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawing_type.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="drawing_number">Drawing Number *</Label>
|
||||
<Input id="drawing_number" {...register("drawing_number")} placeholder="e.g. A-101" />
|
||||
{errors.drawing_number && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawing_number.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sheet_number">Sheet Number *</Label>
|
||||
<Input id="sheet_number" {...register("sheet_number")} placeholder="e.g. 01" />
|
||||
{errors.sheet_number && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.sheet_number.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input id="title" {...register("title")} placeholder="Drawing Title" />
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Discipline *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Discipline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">STR - Structure</SelectItem>
|
||||
<SelectItem value="2">ARC - Architecture</SelectItem>
|
||||
<SelectItem value="3">ELE - Electrical</SelectItem>
|
||||
<SelectItem value="4">MEC - Mechanical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.discipline_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.discipline_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="scale">Scale</Label>
|
||||
<Input id="scale" {...register("scale")} placeholder="e.g. 1:100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="file">Drawing File *</Label>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".pdf,.dwg"
|
||||
className="cursor-pointer"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setValue("file", file);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Accepted: PDF, DWG (Max 50MB)
|
||||
</p>
|
||||
{errors.file && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Upload Drawing
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
114
frontend/components/layout/global-search.tsx
Normal file
114
frontend/components/layout/global-search.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, FileText, Clipboard, Image } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { searchApi } from "@/lib/api/search";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { useDebounce } from "@/hooks/use-debounce"; // We need to create this hook or implement debounce inline
|
||||
|
||||
// Simple debounce hook implementation inline for now if not exists
|
||||
function useDebounceValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function GlobalSearch() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
|
||||
const debouncedQuery = useDebounceValue(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length > 2) {
|
||||
searchApi.suggest(debouncedQuery).then(setSuggestions);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
if (debouncedQuery.length === 0) setOpen(false);
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "correspondence": return <FileText className="mr-2 h-4 w-4" />;
|
||||
case "rfa": return <Clipboard className="mr-2 h-4 w-4" />;
|
||||
case "drawing": return <Image className="mr-2 h-4 w-4" />;
|
||||
default: return <Search className="mr-2 h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Popover open={open && suggestions.length > 0} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search documents..."
|
||||
className="pl-8 w-full bg-background"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup heading="Suggestions">
|
||||
{suggestions.map((item) => (
|
||||
<CommandItem
|
||||
key={`${item.type}-${item.id}`}
|
||||
onSelect={() => {
|
||||
setQuery(item.title);
|
||||
router.push(`/${item.type}s/${item.id}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{getIcon(item.type)}
|
||||
<span>{item.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/components/layout/header.tsx
Normal file
24
frontend/components/layout/header.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { UserMenu } from "./user-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GlobalSearch } from "./global-search";
|
||||
import { NotificationsDropdown } from "./notifications-dropdown";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="h-16 border-b bg-white flex items-center justify-between px-6 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<h2 className="text-lg font-semibold text-gray-800">LCBP3-DMS</h2>
|
||||
<div className="ml-4 w-full max-w-md">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<NotificationsDropdown />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
139
frontend/components/layout/notifications-dropdown.tsx
Normal file
139
frontend/components/layout/notifications-dropdown.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bell, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { notificationApi } from "@/lib/api/notifications";
|
||||
import { Notification } from "@/types/notification";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function NotificationsDropdown() {
|
||||
const router = useRouter();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch notifications
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const data = await notificationApi.getUnread();
|
||||
setNotifications(data.items);
|
||||
setUnreadCount(data.unreadCount);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notifications", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
const handleMarkAsRead = async (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await notificationApi.markAsRead(id);
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.notification_id === id ? { ...n, is_read: true } : n))
|
||||
);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.is_read) {
|
||||
await notificationApi.markAsRead(notification.notification_id);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
setIsOpen(false);
|
||||
if (notification.link) {
|
||||
router.push(notification.link);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-[10px] rounded-full"
|
||||
>
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuLabel className="flex justify-between items-center">
|
||||
<span>Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{unreadCount} unread
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{notifications.map((notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.notification_id}
|
||||
className={`flex flex-col items-start p-3 cursor-pointer ${
|
||||
!notification.is_read ? "bg-muted/30" : ""
|
||||
}`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<div className="flex w-full justify-between items-start gap-2">
|
||||
<div className="font-medium text-sm line-clamp-1">
|
||||
{notification.title}
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => handleMarkAsRead(notification.notification_id, e)}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-2 w-full text-right">
|
||||
{formatDistanceToNow(new Date(notification.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-center justify-center text-xs text-muted-foreground cursor-pointer">
|
||||
View All Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +1,140 @@
|
||||
// File: components/layout/sidebar.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUIStore } from "@/lib/stores/ui-store";
|
||||
import { sidebarMenuItems, adminMenuItems } from "@/config/menu";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
FileCheck,
|
||||
PenTool,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, Menu, X } from "lucide-react";
|
||||
import { useEffect } from "react"; // ✅ Import useEffect
|
||||
import { useState } from "react";
|
||||
import { Can } from "@/components/common/can";
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { isSidebarOpen, toggleSidebar, closeSidebar } = useUIStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// ✅ เพิ่ม Logic นี้: ปิด Sidebar อัตโนมัติเมื่อหน้าจอเล็กกว่า 768px (Mobile)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 768 && isSidebarOpen) {
|
||||
closeSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
// ติดตั้ง Listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// ล้าง Listener เมื่อ Component ถูกทำลาย
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [isSidebarOpen, closeSidebar]);
|
||||
const navItems = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: "/dashboard",
|
||||
icon: LayoutDashboard,
|
||||
permission: null, // Everyone can see
|
||||
},
|
||||
{
|
||||
title: "Correspondences",
|
||||
href: "/correspondences",
|
||||
icon: FileText,
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: "RFAs",
|
||||
href: "/rfas",
|
||||
icon: FileCheck,
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: "Drawings",
|
||||
href: "/drawings",
|
||||
icon: PenTool,
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: "Search",
|
||||
href: "/search",
|
||||
icon: Search,
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
title: "Admin Panel",
|
||||
href: "/admin",
|
||||
icon: Shield,
|
||||
permission: "admin", // Only admins
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-background/80 backdrop-blur-sm transition-all duration-100 md:hidden",
|
||||
isSidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-screen border-r bg-card transition-all duration-300",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b">
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold text-primary truncate">
|
||||
LCBP3 DMS
|
||||
</span>
|
||||
)}
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn("ml-auto", collapsed && "mx-auto")}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed top-0 left-0 z-50 h-screen border-r bg-card transition-all duration-300 ease-in-out flex flex-col",
|
||||
|
||||
// Mobile Width
|
||||
"w-[240px]",
|
||||
isSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<nav className="grid gap-1 px-2">
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
|
||||
// Desktop Styles
|
||||
"md:translate-x-0",
|
||||
isSidebarOpen ? "md:w-[240px]" : "md:w-[70px]"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-14 items-center border-b px-3 lg:h-[60px]",
|
||||
"justify-between md:justify-center",
|
||||
isSidebarOpen && "md:justify-between"
|
||||
)}>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 font-bold text-primary truncate transition-all duration-300",
|
||||
!isSidebarOpen && "md:w-0 md:opacity-0 md:hidden"
|
||||
)}>
|
||||
<Link href="/dashboard">LCBP3 DMS</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className="hidden md:flex h-8 w-8"
|
||||
>
|
||||
{isSidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Mobile Close Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeSidebar} // ปุ่มนี้จะทำงานได้ถูกต้อง
|
||||
className="md:hidden h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-4">
|
||||
<nav className="grid gap-1 px-2">
|
||||
{sidebarMenuItems.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
const LinkComponent = (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground",
|
||||
collapsed && "justify-center px-2"
|
||||
)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (item.permission) {
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground",
|
||||
!isSidebarOpen && "md:justify-center md:px-2"
|
||||
)}
|
||||
title={!isSidebarOpen ? item.title : undefined}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 768) closeSidebar();
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className={cn(
|
||||
"truncate transition-all duration-300",
|
||||
!isSidebarOpen && "md:w-0 md:opacity-0 md:hidden"
|
||||
)}>
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
<Can key={index} permission={item.permission}>
|
||||
{LinkComponent}
|
||||
</Can>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
}
|
||||
|
||||
return LinkComponent;
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
collapsed && "justify-center px-2"
|
||||
)}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
{!collapsed && <span>Settings</span>}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
81
frontend/components/layout/user-menu.tsx
Normal file
81
frontend/components/layout/user-menu.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, Settings, User } from "lucide-react";
|
||||
|
||||
export function UserMenu() {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Generate initials from name or username
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const initials = user.name ? getInitials(user.name) : "U";
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false });
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-primary/10 text-primary">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground mt-1">
|
||||
Role: {user.role}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-red-600 focus:text-red-600">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
92
frontend/components/numbering/sequence-viewer.tsx
Normal file
92
frontend/components/numbering/sequence-viewer.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RefreshCw, Loader2 } from "lucide-react";
|
||||
import { numberingApi } from "@/lib/api/numbering";
|
||||
import { NumberingSequence } from "@/types/numbering";
|
||||
|
||||
export function SequenceViewer({ templateId }: { templateId: number }) {
|
||||
const [sequences, setSequences] = useState<NumberingSequence[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const fetchSequences = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await numberingApi.getSequences(templateId);
|
||||
setSequences(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sequences", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (templateId) {
|
||||
fetchSequences();
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">Number Sequences</h3>
|
||||
<Button variant="outline" size="sm" onClick={fetchSequences} disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder="Search by year, organization..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && sequences.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sequences.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-4">No sequences found.</p>
|
||||
) : (
|
||||
sequences.map((seq) => (
|
||||
<div
|
||||
key={seq.sequence_id}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{seq.year}</span>
|
||||
{seq.organization_code && (
|
||||
<Badge>{seq.organization_code}</Badge>
|
||||
)}
|
||||
{seq.discipline_code && (
|
||||
<Badge variant="outline">{seq.discipline_code}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Current: {seq.current_number} | Last Generated:{" "}
|
||||
{seq.last_generated_number}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Updated {new Date(seq.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
203
frontend/components/numbering/template-editor.tsx
Normal file
203
frontend/components/numbering/template-editor.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CreateTemplateDto } from "@/types/numbering";
|
||||
|
||||
const VARIABLES = [
|
||||
{ key: "{ORG}", name: "Organization Code", example: "กทท" },
|
||||
{ key: "{DOCTYPE}", name: "Document Type", example: "CORR" },
|
||||
{ key: "{DISC}", name: "Discipline", example: "STR" },
|
||||
{ key: "{YYYY}", name: "Year (4-digit)", example: "2025" },
|
||||
{ key: "{YY}", name: "Year (2-digit)", example: "25" },
|
||||
{ key: "{MM}", name: "Month", example: "12" },
|
||||
{ key: "{SEQ}", name: "Sequence Number", example: "0001" },
|
||||
{ key: "{CONTRACT}", name: "Contract Code", example: "C01" },
|
||||
];
|
||||
|
||||
interface TemplateEditorProps {
|
||||
initialData?: Partial<CreateTemplateDto>;
|
||||
onSave: (data: CreateTemplateDto) => void;
|
||||
}
|
||||
|
||||
export function TemplateEditor({ initialData, onSave }: TemplateEditorProps) {
|
||||
const [formData, setFormData] = useState<CreateTemplateDto>({
|
||||
document_type_id: initialData?.document_type_id || "",
|
||||
discipline_code: initialData?.discipline_code || "",
|
||||
template_format: initialData?.template_format || "",
|
||||
reset_annually: initialData?.reset_annually ?? true,
|
||||
padding_length: initialData?.padding_length || 4,
|
||||
starting_number: initialData?.starting_number || 1,
|
||||
});
|
||||
const [preview, setPreview] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Generate preview
|
||||
let previewText = formData.template_format;
|
||||
VARIABLES.forEach((v) => {
|
||||
// Escape special characters for regex if needed, but simple replaceAll is safer for fixed strings
|
||||
previewText = previewText.split(v.key).join(v.example);
|
||||
});
|
||||
setPreview(previewText);
|
||||
}, [formData.template_format]);
|
||||
|
||||
const insertVariable = (variable: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
template_format: prev.template_format + variable,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Template Configuration</h3>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label>Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type_id}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select document type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="correspondence">Correspondence</SelectItem>
|
||||
<SelectItem value="rfa">RFA</SelectItem>
|
||||
<SelectItem value="drawing">Drawing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Discipline (Optional)</Label>
|
||||
<Select
|
||||
value={formData.discipline_code}
|
||||
onValueChange={(value) => setFormData({ ...formData, discipline_code: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All disciplines" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All</SelectItem>
|
||||
<SelectItem value="STR">STR - Structure</SelectItem>
|
||||
<SelectItem value="ARC">ARC - Architecture</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Template Format *</Label>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={formData.template_format}
|
||||
onChange={(e) => setFormData({ ...formData, template_format: e.target.value })}
|
||||
placeholder="e.g., {ORG}-{DOCTYPE}-{YYYY}-{SEQ}"
|
||||
className="font-mono"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VARIABLES.map((v) => (
|
||||
<Button
|
||||
key={v.key}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => insertVariable(v.key)}
|
||||
type="button"
|
||||
>
|
||||
{v.key}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Preview</Label>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground mb-1">Example number:</p>
|
||||
<p className="text-2xl font-mono font-bold text-green-700">
|
||||
{preview || "Enter format above"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Sequence Padding Length</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.padding_length}
|
||||
onChange={(e) => setFormData({ ...formData, padding_length: parseInt(e.target.value) })}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Number of digits (e.g., 4 = 0001, 0002)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Starting Number</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.starting_number}
|
||||
onChange={(e) => setFormData({ ...formData, starting_number: parseInt(e.target.value) })}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={formData.reset_annually}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, reset_annually: checked as boolean })}
|
||||
/>
|
||||
<span className="text-sm">Reset annually (on January 1st)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variable Reference */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Available Variables</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{VARIABLES.map((v) => (
|
||||
<div
|
||||
key={v.key}
|
||||
className="flex items-center justify-between p-2 bg-muted/50 rounded"
|
||||
>
|
||||
<div>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{v.key}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">{v.name}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{v.example}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => window.history.back()}>Cancel</Button>
|
||||
<Button onClick={() => onSave(formData)}>Save Template</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
110
frontend/components/numbering/template-tester.tsx
Normal file
110
frontend/components/numbering/template-tester.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { NumberingTemplate } from "@/types/numbering";
|
||||
import { numberingApi } from "@/lib/api/numbering";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface TemplateTesterProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template: NumberingTemplate | null;
|
||||
}
|
||||
|
||||
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
|
||||
const [testData, setTestData] = useState({
|
||||
organization_id: "1",
|
||||
discipline_id: "1",
|
||||
year: new Date().getFullYear(),
|
||||
});
|
||||
const [generatedNumber, setGeneratedNumber] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!template) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await numberingApi.testTemplate(template.template_id, testData);
|
||||
setGeneratedNumber(result.number);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate test number", error);
|
||||
setGeneratedNumber("Error generating number");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test Number Generation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Organization</Label>
|
||||
<Select
|
||||
value={testData.organization_id}
|
||||
onValueChange={(value) => setTestData({ ...testData, organization_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">กทท.</SelectItem>
|
||||
<SelectItem value="2">สค©.</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Discipline (Optional)</Label>
|
||||
<Select
|
||||
value={testData.discipline_id}
|
||||
onValueChange={(value) => setTestData({ ...testData, discipline_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select discipline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">STR</SelectItem>
|
||||
<SelectItem value="2">ARC</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleTest} className="w-full" disabled={loading || !template}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate Test Number
|
||||
</Button>
|
||||
|
||||
{generatedNumber && (
|
||||
<Card className="p-4 bg-green-50 border-green-200">
|
||||
<p className="text-sm text-muted-foreground mb-1">Generated Number:</p>
|
||||
<p className="text-2xl font-mono font-bold text-green-700">
|
||||
{generatedNumber}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
201
frontend/components/rfas/detail.tsx
Normal file
201
frontend/components/rfas/detail.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { RFA } from "@/types/rfa";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rfaApi } from "@/lib/api/rfas";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface RFADetailProps {
|
||||
data: RFA;
|
||||
}
|
||||
|
||||
export function RFADetail({ data }: RFADetailProps) {
|
||||
const router = useRouter();
|
||||
const [approvalDialog, setApprovalDialog] = useState<"approve" | "reject" | null>(null);
|
||||
const [comments, setComments] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleApproval = async (action: "approve" | "reject") => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const newStatus = action === "approve" ? "APPROVED" : "REJECTED";
|
||||
await rfaApi.updateStatus(data.rfa_id, newStatus, comments);
|
||||
setApprovalDialog(null);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to update status");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/rfas">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{data.rfa_number}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Created on {format(new Date(data.created_at), "dd MMM yyyy HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.status === "PENDING" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setApprovalDialog("reject")}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={() => setApprovalDialog("approve")}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl">{data.subject}</CardTitle>
|
||||
<StatusBadge status={data.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Description</h3>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">
|
||||
{data.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">RFA Items</h3>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">Item No.</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Description</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Qty</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Unit</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3 font-medium">{item.item_no}</td>
|
||||
<td className="px-4 py-3">{item.description}</td>
|
||||
<td className="px-4 py-3 text-right">{item.quantity}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{item.unit}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={item.status || "PENDING"} className="text-[10px] px-2 py-0.5 h-5" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Info */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Contract</p>
|
||||
<p className="font-medium mt-1">{data.contract_name}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Discipline</p>
|
||||
<p className="font-medium mt-1">{data.discipline_name}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval Dialog */}
|
||||
<Dialog open={!!approvalDialog} onOpenChange={(open) => !open && setApprovalDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{approvalDialog === "approve" ? "Approve RFA" : "Reject RFA"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Comments</Label>
|
||||
<Textarea
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Enter your comments here..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setApprovalDialog(null)} disabled={isProcessing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={approvalDialog === "approve" ? "default" : "destructive"}
|
||||
onClick={() => handleApproval(approvalDialog!)}
|
||||
disabled={isProcessing}
|
||||
className={approvalDialog === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{approvalDialog === "approve" ? "Confirm Approval" : "Confirm Rejection"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
frontend/components/rfas/form.tsx
Normal file
237
frontend/components/rfas/form.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { rfaApi } from "@/lib/api/rfas";
|
||||
import { useState } from "react";
|
||||
|
||||
const rfaItemSchema = z.object({
|
||||
item_no: z.string().min(1, "Item No is required"),
|
||||
description: z.string().min(3, "Description is required"),
|
||||
quantity: z.number({ invalid_type_error: "Quantity must be a number" }).min(0),
|
||||
unit: z.string().min(1, "Unit is required"),
|
||||
});
|
||||
|
||||
const rfaSchema = z.object({
|
||||
subject: z.string().min(5, "Subject must be at least 5 characters"),
|
||||
description: z.string().optional(),
|
||||
contract_id: z.number({ required_error: "Contract is required" }),
|
||||
discipline_id: z.number({ required_error: "Discipline is required" }),
|
||||
items: z.array(rfaItemSchema).min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
type RFAFormData = z.infer<typeof rfaSchema>;
|
||||
|
||||
export function RFAForm() {
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<RFAFormData>({
|
||||
resolver: zodResolver(rfaSchema),
|
||||
defaultValues: {
|
||||
items: [{ item_no: "1", description: "", quantity: 0, unit: "" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "items",
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RFAFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await rfaApi.create(data as any);
|
||||
router.push("/rfas");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to create RFA");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">RFA Information</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject *</Label>
|
||||
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
|
||||
{errors.subject && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.subject.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input id="description" {...register("description")} placeholder="Enter description" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Contract *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("contract_id", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Contract" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Main Construction Contract</SelectItem>
|
||||
<SelectItem value="2">Subcontract A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.contract_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.contract_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Discipline *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Discipline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Civil</SelectItem>
|
||||
<SelectItem value="2">Structural</SelectItem>
|
||||
<SelectItem value="3">Electrical</SelectItem>
|
||||
<SelectItem value="4">Mechanical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.discipline_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.discipline_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* RFA Items */}
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">RFA Items</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
item_no: (fields.length + 1).toString(),
|
||||
description: "",
|
||||
quantity: 0,
|
||||
unit: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={field.id} className="p-4 bg-muted/20">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h4 className="font-medium text-sm">Item #{index + 1}</h4>
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-xs">Item No.</Label>
|
||||
<Input {...register(`items.${index}.item_no`)} placeholder="1.1" />
|
||||
{errors.items?.[index]?.item_no && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.items[index]?.item_no?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-6">
|
||||
<Label className="text-xs">Description *</Label>
|
||||
<Input {...register(`items.${index}.description`)} placeholder="Item description" />
|
||||
{errors.items?.[index]?.description && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.items[index]?.description?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-xs">Quantity</Label>
|
||||
<Input
|
||||
type="number"
|
||||
{...register(`items.${index}.quantity`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.items?.[index]?.quantity && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.items[index]?.quantity?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-xs">Unit</Label>
|
||||
<Input {...register(`items.${index}.unit`)} placeholder="pcs, m3" />
|
||||
{errors.items?.[index]?.unit && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.items[index]?.unit?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errors.items?.root && (
|
||||
<p className="text-sm text-destructive mt-2">
|
||||
{errors.items.root.message}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create RFA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
80
frontend/components/rfas/list.tsx
Normal file
80
frontend/components/rfas/list.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { RFA } from "@/types/rfa";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { StatusBadge } from "@/components/common/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface RFAListProps {
|
||||
data: {
|
||||
items: RFA[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function RFAList({ data }: RFAListProps) {
|
||||
const columns: ColumnDef<RFA>[] = [
|
||||
{
|
||||
accessorKey: "rfa_number",
|
||||
header: "RFA No.",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("rfa_number")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: "Subject",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
|
||||
{row.getValue("subject")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "contract_name",
|
||||
header: "Contract",
|
||||
},
|
||||
{
|
||||
accessorKey: "discipline_name",
|
||||
header: "Discipline",
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: "Date",
|
||||
cell: ({ row }) => format(new Date(row.getValue("created_at")), "dd MMM yyyy"),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/rfas/${item.rfa_id}`}>
|
||||
<Button variant="ghost" size="icon" title="View">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable columns={columns} data={data.items} />
|
||||
{/* Pagination component would go here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/components/search/filters.tsx
Normal file
95
frontend/components/search/filters.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchFilters as FilterType } from "@/types/search";
|
||||
import { useState } from "react";
|
||||
|
||||
interface SearchFiltersProps {
|
||||
onFilterChange: (filters: FilterType) => void;
|
||||
}
|
||||
|
||||
export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
|
||||
const [filters, setFilters] = useState<FilterType>({
|
||||
types: [],
|
||||
statuses: [],
|
||||
});
|
||||
|
||||
const handleTypeChange = (type: string, checked: boolean) => {
|
||||
const currentTypes = filters.types || [];
|
||||
const newTypes = checked
|
||||
? [...currentTypes, type]
|
||||
: currentTypes.filter((t) => t !== type);
|
||||
|
||||
const newFilters = { ...filters, types: newTypes };
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: string, checked: boolean) => {
|
||||
const currentStatuses = filters.statuses || [];
|
||||
const newStatuses = checked
|
||||
? [...currentStatuses, status]
|
||||
: currentStatuses.filter((s) => s !== status);
|
||||
|
||||
const newFilters = { ...filters, statuses: newStatuses };
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
const newFilters = { types: [], statuses: [] };
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Document Type</h3>
|
||||
<div className="space-y-2">
|
||||
{["correspondence", "rfa", "drawing"].map((type) => (
|
||||
<div key={type} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`type-${type}`}
|
||||
checked={filters.types?.includes(type)}
|
||||
onCheckedChange={(checked) => handleTypeChange(type, checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor={`type-${type}`} className="text-sm capitalize">
|
||||
{type}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Status</h3>
|
||||
<div className="space-y-2">
|
||||
{["DRAFT", "PENDING", "APPROVED", "REJECTED", "IN_REVIEW"].map((status) => (
|
||||
<div key={status} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`status-${status}`}
|
||||
checked={filters.statuses?.includes(status)}
|
||||
onCheckedChange={(checked) => handleStatusChange(status, checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor={`status-${status}`} className="text-sm capitalize">
|
||||
{status.replace("_", " ").toLowerCase()}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
97
frontend/components/search/results.tsx
Normal file
97
frontend/components/search/results.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { FileText, Clipboard, Image, Loader2 } from "lucide-react";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SearchResults({ results, query, loading }: SearchResultsProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 text-center text-muted-foreground">
|
||||
{query ? `No results found for "${query}"` : "Enter a search term to start"}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "correspondence":
|
||||
return FileText;
|
||||
case "rfa":
|
||||
return Clipboard;
|
||||
case "drawing":
|
||||
return Image;
|
||||
default:
|
||||
return FileText;
|
||||
}
|
||||
};
|
||||
|
||||
const getLink = (result: SearchResult) => {
|
||||
return `/${result.type}s/${result.id}`; // Assuming routes are plural (correspondences, rfas, drawings)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map((result, index) => {
|
||||
const Icon = getIcon(result.type);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${result.type}-${result.id}-${index}`}
|
||||
className="p-6 hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<Link href={getLink(result)}>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<Icon className="h-6 w-6 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3
|
||||
className="text-lg font-semibold group-hover:text-primary transition-colors"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: result.highlight || result.title
|
||||
}}
|
||||
/>
|
||||
<Badge variant="secondary" className="capitalize">{result.type}</Badge>
|
||||
<Badge variant="outline">{result.status}</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">
|
||||
{result.description}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{result.documentNumber}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{format(new Date(result.createdAt), "dd MMM yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/components/workflows/dsl-editor.tsx
Normal file
115
frontend/components/workflows/dsl-editor.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { CheckCircle, AlertCircle, Play, Loader2 } from "lucide-react";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { workflowApi } from "@/lib/api/workflows";
|
||||
import { ValidationResult } from "@/types/workflow";
|
||||
|
||||
interface DSLEditorProps {
|
||||
initialValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function DSLEditor({ initialValue = "", onChange }: DSLEditorProps) {
|
||||
const [dsl, setDsl] = useState(initialValue);
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
const newValue = value || "";
|
||||
setDsl(newValue);
|
||||
onChange?.(newValue);
|
||||
setValidationResult(null); // Clear validation on change
|
||||
};
|
||||
|
||||
const validateDSL = async () => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const result = await workflowApi.validateDSL(dsl);
|
||||
setValidationResult(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setValidationResult({ valid: false, errors: ["Validation failed due to an error"] });
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testWorkflow = async () => {
|
||||
alert("Test workflow functionality to be implemented");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Workflow DSL</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={validateDSL}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isValidating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Validate
|
||||
</Button>
|
||||
<Button variant="outline" onClick={testWorkflow}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border rounded-md">
|
||||
<Editor
|
||||
height="500px"
|
||||
defaultLanguage="yaml"
|
||||
value={dsl}
|
||||
onChange={handleEditorChange}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
lineNumbers: "on",
|
||||
rulers: [80],
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{validationResult && (
|
||||
<Alert variant={validationResult.valid ? "default" : "destructive"} className={validationResult.valid ? "border-green-500 text-green-700 bg-green-50" : ""}>
|
||||
{validationResult.valid ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
<AlertDescription>
|
||||
{validationResult.valid ? (
|
||||
"DSL is valid ✓"
|
||||
) : (
|
||||
<div>
|
||||
<p className="font-medium mb-2">Validation Errors:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{validationResult.errors?.map((error: string, i: number) => (
|
||||
<li key={i} className="text-sm">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
frontend/components/workflows/visual-builder.tsx
Normal file
109
frontend/components/workflows/visual-builder.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
Connection,
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const nodeTypes = {
|
||||
// We can define custom node types here if needed
|
||||
};
|
||||
|
||||
// Color mapping for node types
|
||||
const nodeColors: Record<string, string> = {
|
||||
start: "#10b981", // green
|
||||
step: "#3b82f6", // blue
|
||||
condition: "#f59e0b", // amber
|
||||
end: "#ef4444", // red
|
||||
};
|
||||
|
||||
export function VisualWorkflowBuilder() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
const addNode = (type: string) => {
|
||||
const newNode: Node = {
|
||||
id: `${type}-${Date.now()}`,
|
||||
type: "default", // Using default node type for now
|
||||
position: { x: Math.random() * 400, y: Math.random() * 400 },
|
||||
data: { label: `${type.charAt(0).toUpperCase() + type.slice(1)} Node` },
|
||||
style: {
|
||||
background: nodeColors[type] || "#64748b",
|
||||
color: "white",
|
||||
padding: 10,
|
||||
borderRadius: 5,
|
||||
border: "1px solid #fff",
|
||||
width: 150,
|
||||
},
|
||||
};
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
};
|
||||
|
||||
const generateDSL = () => {
|
||||
// Convert visual workflow to DSL (Mock implementation)
|
||||
const dsl = {
|
||||
name: "Generated Workflow",
|
||||
steps: nodes.map((node) => ({
|
||||
step_name: node.data.label,
|
||||
step_type: "APPROVAL",
|
||||
})),
|
||||
connections: edges.map((edge) => ({
|
||||
from: edge.source,
|
||||
to: edge.target,
|
||||
})),
|
||||
};
|
||||
alert(JSON.stringify(dsl, null, 2));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={() => addNode("start")} variant="outline" size="sm" className="border-green-500 text-green-600 hover:bg-green-50">
|
||||
Add Start
|
||||
</Button>
|
||||
<Button onClick={() => addNode("step")} variant="outline" size="sm" className="border-blue-500 text-blue-600 hover:bg-blue-50">
|
||||
Add Step
|
||||
</Button>
|
||||
<Button onClick={() => addNode("condition")} variant="outline" size="sm" className="border-amber-500 text-amber-600 hover:bg-amber-50">
|
||||
Add Condition
|
||||
</Button>
|
||||
<Button onClick={() => addNode("end")} variant="outline" size="sm" className="border-red-500 text-red-600 hover:bg-red-50">
|
||||
Add End
|
||||
</Button>
|
||||
<Button onClick={generateDSL} className="ml-auto" size="sm">
|
||||
Generate DSL
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="h-[600px] border">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
>
|
||||
<Controls />
|
||||
<Background color="#aaa" gap={16} />
|
||||
</ReactFlow>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user