251208:0010 Backend & Frontend Debug
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
37
frontend/components/auth/auth-sync.tsx
Normal file
37
frontend/components/auth/auth-sync.tsx
Normal 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
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
frontend/components/ui/sonner.tsx
Normal file
29
frontend/components/ui/sonner.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user