251208:0010 Backend & Frontend Debug
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
2025-12-08 00:10:37 +07:00
parent 32d820ea6b
commit dcd126d704
99 changed files with 2775 additions and 1480 deletions

View File

@@ -3,12 +3,10 @@
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";
import { Users, Building2, Settings, FileText, Activity } 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 },
@@ -19,12 +17,16 @@ 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>
<aside className="w-64 border-r bg-card p-4 hidden md:block">
<div className="mb-8 px-2">
<h2 className="text-xl font-bold tracking-tight">Admin Console</h2>
<p className="text-sm text-muted-foreground">LCBP3 DMS</p>
</div>
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
const isActive = pathname.startsWith(item.href);
return (
<Link
@@ -33,8 +35,8 @@ export function AdminSidebar() {
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"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
@@ -43,6 +45,12 @@ export function AdminSidebar() {
);
})}
</nav>
<div className="mt-auto pt-8 px-2 fixed bottom-4 w-56">
<Link href="/dashboard" className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-2">
Back to Dashboard
</Link>
</div>
</aside>
);
}

View File

@@ -13,19 +13,18 @@ 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";
import { useCreateUser, useUpdateUser } from "@/hooks/use-users";
import { useEffect } from "react";
import { User } from "@/types/user";
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(),
username: z.string().min(3),
email: z.string().email(),
first_name: z.string().min(1),
last_name: z.string().min(1),
password: z.string().min(6).optional(),
is_active: z.boolean().default(true),
roles: z.array(z.number()).min(1, "At least one role is required"),
role_ids: z.array(z.number()).default([]), // Using role_ids array
});
type UserFormData = z.infer<typeof userSchema>;
@@ -34,11 +33,11 @@ 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);
export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const {
register,
@@ -50,32 +49,32 @@ export function UserDialog({ open, onOpenChange, user, onSuccess }: UserDialogPr
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
is_active: true,
roles: [],
},
is_active: true,
role_ids: []
}
});
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),
});
reset({
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
is_active: user.is_active,
role_ids: user.roles?.map(r => r.role_id) || []
});
} else {
reset({
username: "",
email: "",
first_name: "",
last_name: "",
is_active: true,
roles: [],
});
reset({
username: "",
email: "",
first_name: "",
last_name: "",
is_active: true,
role_ids: []
});
}
}, [user, reset, open]);
}, [user, reset, open]); // Reset when open changes or user changes
const availableRoles = [
{ role_id: 1, role_name: "ADMIN", description: "System Administrator" },
@@ -83,32 +82,18 @@ export function UserDialog({ open, onOpenChange, user, onSuccess }: UserDialogPr
{ role_id: 3, role_name: "APPROVER", description: "Document Approver" },
];
const selectedRoles = watch("roles") || [];
const selectedRoleIds = watch("role_ids") || [];
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);
const onSubmit = (data: UserFormData) => {
if (user) {
updateUser.mutate({ id: user.user_id, data }, {
onSuccess: () => onOpenChange(false)
});
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createUser.mutate(data as any, {
onSuccess: () => onOpenChange(false)
});
}
};
@@ -122,114 +107,96 @@ export function UserDialog({ open, onOpenChange, user, onSuccess }: UserDialogPr
<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>
)}
<Label>Username *</Label>
<Input {...register("username")} disabled={!!user} />
{errors.username && <p className="text-sm text-red-500">{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>
)}
<Label>Email *</Label>
<Input type="email" {...register("email")} />
{errors.email && <p className="text-sm text-red-500">{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>
)}
<Label>First Name *</Label>
<Input {...register("first_name")} />
</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>
)}
<Label>Last Name *</Label>
<Input {...register("last_name")} />
</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>
)}
<Label>Password *</Label>
<Input type="password" {...register("password")} />
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
</div>
)}
<div>
<Label className="mb-3 block">Roles *</Label>
<div className="space-y-2 border rounded-md p-4">
<Label className="mb-3 block">Roles</Label>
<div className="space-y-2 border p-3 rounded-md">
{availableRoles.map((role) => (
<div
key={role.role_id}
className="flex items-start gap-3"
>
<div key={role.role_id} className="flex items-start space-x-2">
<Checkbox
id={`role-${role.role_id}`}
checked={selectedRoles.includes(role.role_id)}
onCheckedChange={(checked) => handleRoleChange(role.role_id, checked as boolean)}
checked={selectedRoleIds.includes(role.role_id)}
onCheckedChange={(checked) => {
const current = selectedRoleIds;
if (checked) {
setValue("role_ids", [...current, role.role_id]);
} else {
setValue("role_ids", current.filter(id => id !== role.role_id));
}
}}
/>
<div className="grid gap-1.5 leading-none">
<Label
<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">
</label>
<p className="text-xs 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>
{user && (
<div className="flex items-center space-x-2">
<Checkbox
id="is_active"
checked={watch("is_active")}
onCheckedChange={(chk) => setValue("is_active", chk === true)}
/>
<label
htmlFor="is_active"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Active User
</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" />}
<Button type="submit" disabled={createUser.isPending || updateUser.isPending}>
{user ? "Update User" : "Create User"}
</Button>
</div>

View File

@@ -0,0 +1,37 @@
'use client';
import { useSession } from 'next-auth/react';
import { useEffect } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
export function AuthSync() {
const { data: session, status } = useSession();
const { setAuth, logout } = useAuthStore();
useEffect(() => {
if (status === 'authenticated' && session?.user) {
// Map NextAuth session to AuthStore user
// Assuming session.user has the fields we need based on types/next-auth.d.ts
// cast to any or specific type if needed, as NextAuth types might need assertion
const user = session.user as any;
setAuth(
{
id: user.id || user.user_id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
permissions: user.permissions // If backend/auth.ts provides this
},
session.accessToken || '' // If we store token in session
);
} else if (status === 'unauthenticated') {
logout();
}
}, [session, status, setAuth, logout]);
return null; // This component renders nothing
}

View File

@@ -1,27 +1,42 @@
"use client";
// File: components/common/can.tsx
'use client';
import { useSession } from "next-auth/react";
import { ReactNode } from "react";
import { useAuthStore } from '@/lib/stores/auth-store';
import { ReactNode } from 'react';
interface CanProps {
permission: string;
permission?: string;
role?: string;
children: ReactNode;
fallback?: ReactNode;
// Logic: OR (default) - if multiple provided, any match is enough?
// For simplicity, let's enforce: if permission provided -> check permission.
// If role provided -> check role. If both -> check both (AND/OR needs definition).
// Let's go with: if multiple props are provided, ALL must pass (AND logic) for now, or just handle one.
// Common use case: <Can permission="x">
}
export function Can({ permission, children }: CanProps) {
const { data: session } = useSession();
export function Can({
permission,
role,
children,
fallback = null,
}: CanProps) {
const { hasPermission, hasRole } = useAuthStore();
if (!session?.user) {
return null;
let allowed = true;
if (permission && !hasPermission(permission)) {
allowed = false;
}
const userRole = session.user.role;
// Simple role-based check
// If the user's role matches the required permission (role), allow access.
if (userRole === permission) {
return <>{children}</>;
if (role && !hasRole(role)) {
allowed = false;
}
return null;
if (!allowed) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -16,9 +16,9 @@ import {
} 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";
import { useCreateCorrespondence } from "@/hooks/use-correspondence";
import { useOrganizations } from "@/hooks/use-master-data";
const correspondenceSchema = z.object({
subject: z.string().min(5, "Subject must be at least 5 characters"),
@@ -34,7 +34,8 @@ type FormData = z.infer<typeof correspondenceSchema>;
export function CorrespondenceForm() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const createMutation = useCreateCorrespondence();
const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations();
const {
register,
@@ -50,18 +51,12 @@ export function CorrespondenceForm() {
},
});
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);
}
const onSubmit = (data: FormData) => {
createMutation.mutate(data as any, {
onSuccess: () => {
router.push("/correspondences");
},
});
};
return (
@@ -92,15 +87,17 @@ export function CorrespondenceForm() {
<Label>From Organization *</Label>
<Select
onValueChange={(v) => setValue("from_organization_id", parseInt(v))}
disabled={isLoadingOrgs}
>
<SelectTrigger>
<SelectValue placeholder="Select Organization" />
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "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>
{organizations?.map((org: any) => (
<SelectItem key={org.id} value={String(org.id)}>
{org.name || org.org_name} ({org.code || org.org_code})
</SelectItem>
))}
</SelectContent>
</Select>
{errors.from_organization_id && (
@@ -112,14 +109,17 @@ export function CorrespondenceForm() {
<Label>To Organization *</Label>
<Select
onValueChange={(v) => setValue("to_organization_id", parseInt(v))}
disabled={isLoadingOrgs}
>
<SelectTrigger>
<SelectValue placeholder="Select Organization" />
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Contractor A (CON-A)</SelectItem>
<SelectItem value="2">Owner (OWN)</SelectItem>
<SelectItem value="3">Consultant (CNS)</SelectItem>
{organizations?.map((org: any) => (
<SelectItem key={org.id} value={String(org.id)}>
{org.name || org.org_name} ({org.code || org.org_code})
</SelectItem>
))}
</SelectContent>
</Select>
{errors.to_organization_id && (
@@ -177,8 +177,8 @@ export function CorrespondenceForm() {
<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" />}
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Correspondence
</Button>
</div>

View File

@@ -10,7 +10,7 @@ import Link from "next/link";
import { format } from "date-fns";
interface CorrespondenceListProps {
data: {
data?: {
items: Correspondence[];
total: number;
page: number;
@@ -80,7 +80,7 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
return (
<div>
<DataTable columns={columns} data={data.items} />
<DataTable columns={columns} data={data?.items || []} />
{/* Pagination component would go here, receiving props from data */}
</div>
);

View File

@@ -7,10 +7,28 @@ import { PendingTask } from "@/types/dashboard";
import { AlertCircle, ArrowRight } from "lucide-react";
interface PendingTasksProps {
tasks: PendingTask[];
tasks: PendingTask[] | undefined;
isLoading: boolean;
}
export function PendingTasks({ tasks }: PendingTasksProps) {
export function PendingTasks({ tasks, isLoading }: PendingTasksProps) {
if (isLoading) {
return (
<Card className="h-full">
<CardHeader><CardTitle className="text-lg">Pending Tasks</CardTitle></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
))}
</div>
</CardContent>
</Card>
)
}
if (!tasks) tasks = [];
return (
<Card className="h-full">
<CardHeader>

View File

@@ -8,10 +8,36 @@ import { ActivityLog } from "@/types/dashboard";
import Link from "next/link";
interface RecentActivityProps {
activities: ActivityLog[];
activities: ActivityLog[] | undefined;
isLoading: boolean;
}
export function RecentActivity({ activities }: RecentActivityProps) {
export function RecentActivity({ activities, isLoading }: RecentActivityProps) {
if (isLoading) {
return (
<Card className="h-full">
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
<CardContent>
<div className="space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded-md" />
))}
</div>
</CardContent>
</Card>
)
}
if (!activities || activities.length === 0) {
return (
<Card className="h-full">
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
<CardContent className="text-muted-foreground text-sm text-center py-8">
No recent activity.
</CardContent>
</Card>
);
}
return (
<Card className="h-full">
<CardHeader>

View File

@@ -4,11 +4,21 @@ import { Card } from "@/components/ui/card";
import { FileText, Clipboard, CheckCircle, Clock } from "lucide-react";
import { DashboardStats } from "@/types/dashboard";
interface StatsCardsProps {
stats: DashboardStats;
export interface StatsCardsProps {
stats: DashboardStats | undefined;
isLoading: boolean;
}
export function StatsCards({ stats }: StatsCardsProps) {
export function StatsCards({ stats, isLoading }: StatsCardsProps) {
if (isLoading || !stats) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<Card key={i} className="p-6 h-[100px] animate-pulse bg-muted" />
))}
</div>
);
}
const cards = [
{
title: "Total Correspondences",

View File

@@ -1,9 +1,7 @@
"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 { useDrawings } from "@/hooks/use-drawing";
import { Loader2 } from "lucide-react";
interface DrawingListProps {
@@ -11,26 +9,12 @@ interface DrawingListProps {
}
export function DrawingList({ type }: DrawingListProps) {
const [drawings, setDrawings] = useState<Drawing[]>([]);
const [loading, setLoading] = useState(true);
const { data: drawings, isLoading, isError } = useDrawings(type, { type });
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);
}
};
// Note: The hook handles switching services based on type.
// The params { type } might be redundant if getAll doesn't use it, but safe to pass.
fetchDrawings();
}, [type]);
if (loading) {
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -38,7 +22,15 @@ export function DrawingList({ type }: DrawingListProps) {
);
}
if (drawings.length === 0) {
if (isError) {
return (
<div className="text-center py-12 text-red-500">
Failed to load drawings.
</div>
);
}
if (!drawings || drawings.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
No drawings found.
@@ -48,8 +40,8 @@ export function DrawingList({ type }: DrawingListProps) {
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} />
{drawings.map((drawing: any) => (
<DrawingCard key={drawing[type === 'CONTRACT' ? 'contract_drawing_id' : 'shop_drawing_id'] || drawing.id || drawing.drawing_id} drawing={drawing} />
))}
</div>
);

View File

@@ -15,7 +15,8 @@ import {
} from "@/components/ui/select";
import { Card } from "@/components/ui/card";
import { useRouter } from "next/navigation";
import { drawingApi } from "@/lib/api/drawings";
import { useCreateDrawing } from "@/hooks/use-drawing";
import { useDisciplines } from "@/hooks/use-master-data";
import { useState } from "react";
import { Loader2 } from "lucide-react";
@@ -26,40 +27,111 @@ const drawingSchema = z.object({
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" }),
file: z.instanceof(File, { message: "File is required" }), // In real app, might validation creation before upload
});
type DrawingFormData = z.infer<typeof drawingSchema>;
export function DrawingUploadForm() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Discipline Hook
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines();
const {
register,
handleSubmit,
setValue,
watch,
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);
}
const drawingType = watch("drawing_type");
const createMutation = useCreateDrawing(drawingType); // Hook depends on type but defaults to undefined initially which is fine or handled
const onSubmit = (data: DrawingFormData) => {
// Only proceed if createMutation is valid for the type (it should be since we watch type)
if (!drawingType) return;
// Convert to FormData
// Note: Backend might expect JSON Body or Multipart/Form-Data depending on implementation.
// Assuming Multipart/Form-Data if file is involved, OR
// Two-step upload: 1. Upload File -> Get URL 2. Create Record with URL.
// The previous code assumed direct call.
// Let's assume the service handles FormData conversion if we pass plain object or we construct here.
// My previous assumption in implementation plan: "File upload will use FormData".
// I should check service again. `contract-drawing.service` takes `CreateContractDrawingDto`.
// Usually NestJS with FileUpload uses Interceptors and FormData.
// Creating FormData manually to be safe for file upload
/*
const formData = new FormData();
formData.append('title', data.title);
// ...
// BUT useCreateDrawing calls service.create(data). Service uses apiClient.post(data).
// axios handles FormData automatically if passed directly, but nested objects are tricky.
// Let's pass the raw DTO and hope services handle it or assume Backend accepts DTO JSON and file separately?
// Actually standard Axios with FormData:
*/
// Let's try to construct FormData here as generic approach for file uploads
// However, if I change the argument to FormData, Types might complain.
// Let's just pass `data` and let the developer (me) ensure Service handles it correctly or modify service later if failed.
// Wait, `contractDrawingService.create` takes `CreateContractDrawingDto`.
// I will assume for now I pass the object. If file upload fails, I will fix service.
// Actually better to handle FormData logic here since we have the File object
const formData = new FormData();
formData.append('drawing_number', data.drawing_number);
formData.append('title', data.title);
formData.append('discipline_id', String(data.discipline_id));
formData.append('sheet_number', data.sheet_number);
if(data.scale) formData.append('scale', data.scale);
formData.append('file', data.file);
// Type specific fields if any? (Project ID?)
// Contract/Shop might have different fields. Assuming minimal common set.
createMutation.mutate(data as any, { // Passing raw data or FormData? Hook awaits 'any'.
// If I pass FormData, Axios sends it as multipart/form-data.
// If I pass JSON, it sends as JSON (and File is empty object).
// Since there is a File, I MUST use FormData for it to work with standard uploads.
// But wait, the `useCreateDrawing` calls `service.create` which calls `apiClient.post`.
// If I pass FormData to `mutate`, it goes to `service.create`.
// So I will pass FormData but `data as any` above cast allows it.
// BUT `data` argument in `onSubmit` is `DrawingFormData` (Object).
// I will pass `formData` to mutate.
// WARNING: Hooks expects correct type. I used `any` in hook definition.
onSuccess: () => {
router.push("/drawings");
}
});
};
// Actually, to make it work with TypeScript and `mutate`, let's wrap logic
const handleFormSubmit = (data: DrawingFormData) => {
// Create FormData
const formData = new FormData();
Object.keys(data).forEach(key => {
if (key === 'file') {
formData.append(key, data.file);
} else {
formData.append(key, String((data as any)[key]));
}
});
// Append projectId if needed (hardcoded 1 for now)
formData.append('projectId', '1');
createMutation.mutate(formData as any, {
onSuccess: () => {
router.push("/drawings");
}
});
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
<form onSubmit={handleSubmit(handleFormSubmit)} className="max-w-2xl space-y-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
@@ -110,15 +182,17 @@ export function DrawingUploadForm() {
<Label>Discipline *</Label>
<Select
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
disabled={isLoadingDisciplines}
>
<SelectTrigger>
<SelectValue placeholder="Select Discipline" />
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "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>
{disciplines?.map((d: any) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name} ({d.code})
</SelectItem>
))}
</SelectContent>
</Select>
{errors.discipline_id && (
@@ -157,8 +231,8 @@ export function DrawingUploadForm() {
<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" />}
<Button type="submit" disabled={createMutation.isPending || !drawingType}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Upload Drawing
</Button>
</div>

View File

@@ -2,21 +2,18 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Search, FileText, Clipboard, Image } from "lucide-react";
import { Search, FileText, Clipboard, Image, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Command, CommandEmpty, CommandGroup, CommandItem, CommandList,
Command, 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
import { useSearchSuggestions } from "@/hooks/use-search";
// 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(() => {
@@ -34,19 +31,18 @@ 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);
const { data: suggestions, isLoading } = useSearchSuggestions(debouncedQuery);
useEffect(() => {
if (debouncedQuery.length > 2) {
searchApi.suggest(debouncedQuery).then(setSuggestions);
if (debouncedQuery.length > 2 && suggestions && suggestions.length > 0) {
setOpen(true);
} else {
setSuggestions([]);
if (debouncedQuery.length === 0) setOpen(false);
}
}, [debouncedQuery]);
}, [debouncedQuery, suggestions]);
const handleSearch = () => {
if (query.trim()) {
@@ -66,7 +62,7 @@ export function GlobalSearch() {
return (
<div className="relative w-full max-w-sm">
<Popover open={open && suggestions.length > 0} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -78,29 +74,42 @@ export function GlobalSearch() {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
onFocus={() => {
if (suggestions.length > 0) setOpen(true);
if (suggestions && suggestions.length > 0) setOpen(true);
}}
/>
{isLoading && (
<Loader2 className="absolute right-2.5 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]" align="start">
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<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>
{suggestions && suggestions.length > 0 && (
<CommandGroup heading="Suggestions">
{suggestions.map((item: any) => (
<CommandItem
key={`${item.type}-${item.id}`}
onSelect={() => {
setQuery(item.title);
// Assumption: item has type and id.
// If type is missing, we might need a map or check usage in backend response
router.push(`/${item.type}s/${item.id}`);
setOpen(false);
}}
>
{getIcon(item.type)}
<span className="truncate">{item.title}</span>
<span className="ml-auto text-xs text-muted-foreground">{item.documentNumber}</span>
</CommandItem>
))}
</CommandGroup>
)}
{(!suggestions || suggestions.length === 0) && !isLoading && (
<div className="py-6 text-center text-sm text-muted-foreground">
No suggestions found.
</div>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { Bell, Check } from "lucide-react";
import { Bell, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -12,62 +11,36 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { notificationApi } from "@/lib/api/notifications";
import { Notification } from "@/types/notification";
import { useNotifications, useMarkNotificationRead } from "@/hooks/use-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);
const { data, isLoading } = useNotifications();
const markAsRead = useMarkNotificationRead();
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);
}
};
const notifications = data?.items || [];
const unreadCount = data?.unreadCount || 0;
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) => {
const handleNotificationClick = (notification: any) => {
if (!notification.is_read) {
await notificationApi.markAsRead(notification.notification_id);
setUnreadCount((prev) => Math.max(0, prev - 1));
markAsRead.mutate(notification.notification_id);
}
setIsOpen(false);
if (notification.link) {
router.push(notification.link);
}
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5 text-gray-600" />
{unreadCount > 0 && (
<Bell className="h-5 w-5" />
{unreadCount > 0 && !isLoading && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-[10px] rounded-full"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
{unreadCount}
</Badge>
@@ -76,50 +49,35 @@ export function NotificationsDropdown() {
</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>
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No notifications
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No new notifications
</div>
) : (
<div className="max-h-[400px] overflow-y-auto">
{notifications.map((notification) => (
<div className="max-h-96 overflow-y-auto">
{notifications.slice(0, 5).map((notification: any) => (
<DropdownMenuItem
key={notification.notification_id}
className={`flex flex-col items-start p-3 cursor-pointer ${
!notification.is_read ? "bg-muted/30" : ""
!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 className="flex justify-between w-full">
<span className="font-medium text-sm">{notification.title}</span>
{!notification.is_read && <span className="h-2 w-2 rounded-full bg-blue-500 mt-1" />}
</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">
<div className="text-[10px] text-muted-foreground mt-1 self-end">
{formatDistanceToNow(new Date(notification.created_at), {
addSuffix: true,
})}
@@ -130,8 +88,8 @@ export function NotificationsDropdown() {
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-center justify-center text-xs text-muted-foreground cursor-pointer">
View All Notifications
<DropdownMenuItem className="text-center justify-center text-xs text-muted-foreground" disabled>
View All Notifications (Coming Soon)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -18,8 +18,9 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { rfaApi } from "@/lib/api/rfas";
import { rfaApi } from "@/lib/api/rfas"; // Deprecated, remove if possible
import { useRouter } from "next/navigation";
import { useProcessRFA } from "@/hooks/use-rfa";
interface RFADetailProps {
data: RFA;
@@ -29,21 +30,26 @@ 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 processMutation = useProcessRFA();
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);
}
const apiAction = action === "approve" ? "APPROVE" : "REJECT";
processMutation.mutate(
{
id: data.rfa_id,
data: {
action: apiAction,
comments: comments,
},
},
{
onSuccess: () => {
setApprovalDialog(null);
// Query invalidation handled in hook
},
}
);
};
return (
@@ -181,16 +187,16 @@ export function RFADetail({ data }: RFADetailProps) {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setApprovalDialog(null)} disabled={isProcessing}>
<Button variant="outline" onClick={() => setApprovalDialog(null)} disabled={processMutation.isPending}>
Cancel
</Button>
<Button
variant={approvalDialog === "approve" ? "default" : "destructive"}
onClick={() => handleApproval(approvalDialog!)}
disabled={isProcessing}
disabled={processMutation.isPending}
className={approvalDialog === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
>
{isProcessing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{approvalDialog === "approve" ? "Confirm Approval" : "Confirm Rejection"}
</Button>
</DialogFooter>

View File

@@ -16,7 +16,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useRouter } from "next/navigation";
import { rfaApi } from "@/lib/api/rfas";
import { useCreateRFA } from "@/hooks/use-rfa";
import { useDisciplines } from "@/hooks/use-master-data";
import { useState } from "react";
const rfaItemSchema = z.object({
@@ -38,7 +39,11 @@ type RFAFormData = z.infer<typeof rfaSchema>;
export function RFAForm() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const createMutation = useCreateRFA();
// Fetch Disciplines (Assuming Contract 1 for now, or dynamic)
const selectedContractId = 1;
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
const {
register,
@@ -49,6 +54,7 @@ export function RFAForm() {
} = useForm<RFAFormData>({
resolver: zodResolver(rfaSchema),
defaultValues: {
contract_id: 1,
items: [{ item_no: "1", description: "", quantity: 0, unit: "" }],
},
});
@@ -58,18 +64,12 @@ export function RFAForm() {
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);
}
const onSubmit = (data: RFAFormData) => {
createMutation.mutate(data as any, {
onSuccess: () => {
router.push("/rfas");
},
});
};
return (
@@ -99,13 +99,14 @@ export function RFAForm() {
<Label>Contract *</Label>
<Select
onValueChange={(v) => setValue("contract_id", parseInt(v))}
defaultValue="1"
>
<SelectTrigger>
<SelectValue placeholder="Select Contract" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Main Construction Contract</SelectItem>
<SelectItem value="2">Subcontract A</SelectItem>
{/* Additional contracts can be fetched via API too */}
</SelectContent>
</Select>
{errors.contract_id && (
@@ -117,15 +118,20 @@ export function RFAForm() {
<Label>Discipline *</Label>
<Select
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
disabled={isLoadingDisciplines}
>
<SelectTrigger>
<SelectValue placeholder="Select Discipline" />
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Civil</SelectItem>
<SelectItem value="2">Structural</SelectItem>
<SelectItem value="3">Electrical</SelectItem>
<SelectItem value="4">Mechanical</SelectItem>
{disciplines?.map((d: any) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name} ({d.code})
</SelectItem>
))}
{!isLoadingDisciplines && !disciplines?.length && (
<SelectItem value="0" disabled>No disciplines found</SelectItem>
)}
</SelectContent>
</Select>
{errors.discipline_id && (
@@ -227,8 +233,8 @@ export function RFAForm() {
<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" />}
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create RFA
</Button>
</div>

View File

@@ -0,0 +1,29 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
export function Toaster({ ...props }: ToasterProps) {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}