260322:1648 Correct Coresspondence / Doing RFA / Correct CI
CI Pipeline / build (push) Failing after 12m41s
Build and Deploy / deploy (push) Failing after 2m44s

This commit is contained in:
admin
2026-03-22 16:48:12 +07:00
parent e5deedb42e
commit 11984bfa29
683 changed files with 105251 additions and 29068 deletions
@@ -1,42 +1,42 @@
"use client";
'use client';
import { useParams } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { circulationService } from "@/lib/services/circulation.service";
import { Circulation, UpdateCirculationRoutingDto } from "@/types/circulation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ArrowLeft, RefreshCw, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { circulationService } from '@/lib/services/circulation.service';
import { Circulation, UpdateCirculationRoutingDto } from '@/types/circulation';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowLeft, RefreshCw, CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
import { toast } from 'sonner';
/**
* Get initials from name
*/
function getInitials(firstName?: string, lastName?: string): string {
const first = firstName?.charAt(0) || "";
const last = lastName?.charAt(0) || "";
return (first + last).toUpperCase() || "?";
const first = firstName?.charAt(0) || '';
const last = lastName?.charAt(0) || '';
return (first + last).toUpperCase() || '?';
}
/**
* Get status badge variant
*/
function getStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status?.toUpperCase()) {
case "PENDING":
return "outline";
case "IN_PROGRESS":
return "default";
case "COMPLETED":
return "secondary";
case "REJECTED":
return "destructive";
case 'PENDING':
return 'outline';
case 'IN_PROGRESS':
return 'default';
case 'COMPLETED':
return 'secondary';
case 'REJECTED':
return 'destructive';
default:
return "outline";
return 'outline';
}
}
@@ -45,8 +45,12 @@ export default function CirculationDetailPage() {
const queryClient = useQueryClient();
const uuid = params.uuid as string;
const { data: circulation, isLoading, error } = useQuery<Circulation>({
queryKey: ["circulation", uuid],
const {
data: circulation,
isLoading,
error,
} = useQuery<Circulation>({
queryKey: ['circulation', uuid],
queryFn: () => circulationService.getByUuid(uuid),
enabled: !!uuid,
});
@@ -55,18 +59,18 @@ export default function CirculationDetailPage() {
mutationFn: ({ routingId, data }: { routingId: number; data: UpdateCirculationRoutingDto }) =>
circulationService.updateRouting(routingId, data),
onSuccess: () => {
toast.success("Task completed successfully");
queryClient.invalidateQueries({ queryKey: ["circulation", uuid] });
toast.success('Task completed successfully');
queryClient.invalidateQueries({ queryKey: ['circulation', uuid] });
},
onError: () => {
toast.error("Failed to update task status");
toast.error('Failed to update task status');
},
});
const handleComplete = (routingId: number) => {
completeMutation.mutate({
routingId,
data: { status: "COMPLETED", comments: "Completed via UI" },
data: { status: 'COMPLETED', comments: 'Completed via UI' },
});
};
@@ -109,9 +113,7 @@ export default function CirculationDetailPage() {
<p className="text-muted-foreground">{circulation.subject}</p>
</div>
</div>
<Badge variant={getStatusVariant(circulation.statusCode)}>
{circulation.statusCode}
</Badge>
<Badge variant={getStatusVariant(circulation.statusCode)}>{circulation.statusCode}</Badge>
</div>
{/* Info Card */}
@@ -122,24 +124,20 @@ export default function CirculationDetailPage() {
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Organization</p>
<p className="font-medium">
{circulation.organization?.organization_name || "-"}
</p>
<p className="font-medium">{circulation.organization?.organization_name || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Created By</p>
<p className="font-medium">
{circulation.creator
? `${circulation.creator.first_name || ""} ${circulation.creator.last_name || ""}`.trim() ||
? `${circulation.creator.first_name || ''} ${circulation.creator.last_name || ''}`.trim() ||
circulation.creator.username
: "-"}
: '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Created At</p>
<p className="font-medium">
{format(new Date(circulation.createdAt), "dd MMM yyyy, HH:mm")}
</p>
<p className="font-medium">{format(new Date(circulation.createdAt), 'dd MMM yyyy, HH:mm')}</p>
</div>
{circulation.correspondence && (
<div>
@@ -164,25 +162,19 @@ export default function CirculationDetailPage() {
{circulation.routings && circulation.routings.length > 0 ? (
<div className="space-y-3">
{circulation.routings.map((routing) => (
<div
key={routing.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div key={routing.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{getInitials(
routing.assignee?.first_name,
routing.assignee?.last_name
)}
{getInitials(routing.assignee?.first_name, routing.assignee?.last_name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{routing.assignee
? `${routing.assignee.first_name || ""} ${routing.assignee.last_name || ""}`.trim() ||
? `${routing.assignee.first_name || ''} ${routing.assignee.last_name || ''}`.trim() ||
routing.assignee.username
: "Unassigned"}
: 'Unassigned'}
</p>
<p className="text-sm text-muted-foreground">
Step {routing.stepNumber}
@@ -191,10 +183,8 @@ export default function CirculationDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={getStatusVariant(routing.status)}>
{routing.status}
</Badge>
{routing.status === "PENDING" && (
<Badge variant={getStatusVariant(routing.status)}>{routing.status}</Badge>
{routing.status === 'PENDING' && (
<Button
size="sm"
onClick={() => handleComplete(routing.id)}
+53 -100
View File
@@ -1,47 +1,29 @@
"use client";
'use client';
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { circulationService } from "@/lib/services/circulation.service";
import { userService } from "@/lib/services/user.service";
import { correspondenceService } from "@/lib/services/correspondence.service";
import { CreateCirculationDto } from "@/types/circulation";
import { circulationService } from '@/lib/services/circulation.service';
import { userService } from '@/lib/services/user.service';
import { correspondenceService } from '@/lib/services/correspondence.service';
import { CreateCirculationDto } from '@/types/circulation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Check, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Check, ChevronsUpDown, X } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -51,9 +33,9 @@ export const fetchCache = 'force-no-store';
// Form validation schema
const formSchema = z.object({
correspondenceId: z.string().min(1, "Please select a document"),
subject: z.string().min(1, "Subject is required"),
assigneeIds: z.array(z.string()).min(1, "At least one assignee is required"),
correspondenceId: z.string().min(1, 'Please select a document'),
subject: z.string().min(1, 'Subject is required'),
assigneeIds: z.array(z.string()).min(1, 'At least one assignee is required'),
remarks: z.string().optional(),
});
@@ -67,32 +49,32 @@ export default function CreateCirculationPage() {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
subject: "",
subject: '',
assigneeIds: [],
remarks: "",
remarks: '',
},
});
// Fetch users for assignee selection
const { data: users = [] } = useQuery({
queryKey: ["users"],
queryKey: ['users'],
queryFn: () => userService.getAll(),
});
// Fetch correspondences for document selection
const { data: correspondences } = useQuery({
queryKey: ["correspondences-dropdown"],
queryKey: ['correspondences-dropdown'],
queryFn: () => correspondenceService.getAll({ limit: 100 }),
});
const createMutation = useMutation({
mutationFn: (data: CreateCirculationDto) => circulationService.create(data),
onSuccess: (result) => {
toast.success("Circulation created successfully");
toast.success('Circulation created successfully');
router.push(`/circulation/${result.uuid}`);
},
onError: () => {
toast.error("Failed to create circulation");
toast.error('Failed to create circulation');
},
});
@@ -100,22 +82,20 @@ export default function CreateCirculationPage() {
createMutation.mutate(data);
};
const selectedAssignees = form.watch("assigneeIds");
const selectedDocId = form.watch("correspondenceId");
const selectedAssignees = form.watch('assigneeIds');
const selectedDocId = form.watch('correspondenceId');
const selectedDoc = correspondences?.data?.find(
(c: { uuid: string }) => c.uuid === selectedDocId
);
const selectedDoc = correspondences?.data?.find((c: { uuid: string }) => c.uuid === selectedDocId);
const toggleAssignee = (userUuid: string) => {
const current = form.getValues("assigneeIds");
const current = form.getValues('assigneeIds');
if (current.includes(userUuid)) {
form.setValue(
"assigneeIds",
'assigneeIds',
current.filter((id) => id !== userUuid)
);
} else {
form.setValue("assigneeIds", [...current, userUuid]);
form.setValue('assigneeIds', [...current, userUuid]);
}
};
@@ -130,9 +110,7 @@ export default function CreateCirculationPage() {
</Link>
<div>
<h1 className="text-2xl font-bold">Create Circulation</h1>
<p className="text-muted-foreground">
Create a new internal document circulation
</p>
<p className="text-muted-foreground">Create a new internal document circulation</p>
</div>
</div>
@@ -156,14 +134,9 @@ export default function CreateCirculationPage() {
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value && "text-muted-foreground"
)}
className={cn('justify-between', !field.value && 'text-muted-foreground')}
>
{selectedDoc
? selectedDoc.correspondenceNumber
: "Select document..."}
{selectedDoc ? selectedDoc.correspondenceNumber : 'Select document...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
@@ -179,16 +152,14 @@ export default function CreateCirculationPage() {
key={doc.uuid}
value={doc.correspondenceNumber}
onSelect={() => {
form.setValue("correspondenceId", doc.uuid);
form.setValue('correspondenceId', doc.uuid);
setDocOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
doc.uuid === field.value
? "opacity-100"
: "opacity-0"
'mr-2 h-4 w-4',
doc.uuid === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
{doc.correspondenceNumber}
@@ -223,29 +194,19 @@ export default function CreateCirculationPage() {
<FormField
control={form.control}
name="assigneeIds"
render={({ field }) => (
render={({ _field }) => (
<FormItem className="flex flex-col">
<FormLabel>Assignees</FormLabel>
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="justify-between h-auto min-h-10"
>
<Button variant="outline" role="combobox" className="justify-between h-auto min-h-10">
{selectedAssignees.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedAssignees.map((userUuid) => {
const user = users.find(
(u) => u.uuid === userUuid
);
const user = users.find((u) => u.uuid === userUuid);
return user ? (
<Badge
key={userUuid}
variant="secondary"
className="mr-1"
>
<Badge key={userUuid} variant="secondary" className="mr-1">
{user.firstName || user.username}
<X
className="ml-1 h-3 w-3 cursor-pointer"
@@ -259,9 +220,7 @@ export default function CreateCirculationPage() {
})}
</div>
) : (
<span className="text-muted-foreground">
Select assignees...
</span>
<span className="text-muted-foreground">Select assignees...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -281,10 +240,8 @@ export default function CreateCirculationPage() {
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedAssignees.includes(user.uuid)
? "opacity-100"
: "opacity-0"
'mr-2 h-4 w-4',
selectedAssignees.includes(user.uuid) ? 'opacity-100' : 'opacity-0'
)}
/>
{user.firstName && user.lastName
@@ -310,11 +267,7 @@ export default function CreateCirculationPage() {
<FormItem>
<FormLabel>Remarks (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Additional notes..."
className="resize-none"
{...field}
/>
<Textarea placeholder="Additional notes..." className="resize-none" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -329,7 +282,7 @@ export default function CreateCirculationPage() {
</Button>
</Link>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating..." : "Create Circulation"}
{createMutation.isPending ? 'Creating...' : 'Create Circulation'}
</Button>
</div>
</form>
+14 -29
View File
@@ -1,24 +1,19 @@
"use client";
'use client';
import { useQuery } from "@tanstack/react-query";
import { CirculationList } from "@/components/circulation/circulation-list";
import { circulationService } from "@/lib/services/circulation.service";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link";
import { CirculationListResponse } from "@/types/circulation";
import { useQuery } from '@tanstack/react-query';
import { CirculationList } from '@/components/circulation/circulation-list';
import { circulationService } from '@/lib/services/circulation.service';
import { Button } from '@/components/ui/button';
import { Plus, RefreshCw } from 'lucide-react';
import Link from 'next/link';
import { CirculationListResponse } from '@/types/circulation';
/**
* Circulation list page - displays circulations for the current user's organization
*/
export default function CirculationPage() {
const {
data,
isLoading,
error,
refetch,
} = useQuery<CirculationListResponse>({
queryKey: ["circulations"],
const { data, isLoading, error, refetch } = useQuery<CirculationListResponse>({
queryKey: ['circulations'],
queryFn: () => circulationService.getAll(),
});
@@ -27,19 +22,11 @@ export default function CirculationPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Circulation</h1>
<p className="text-muted-foreground">
Manage internal document circulation and assignments
</p>
<p className="text-muted-foreground">Manage internal document circulation and assignments</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
title="Refresh"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading} title="Refresh">
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Link href="/circulation/new">
<Button>
@@ -63,9 +50,7 @@ export default function CirculationPage() {
) : data ? (
<CirculationList data={data} />
) : (
<div className="text-center py-12 text-muted-foreground">
No circulations found
</div>
<div className="text-center py-12 text-muted-foreground">No circulations found</div>
)}
</section>
);
@@ -1,9 +1,9 @@
"use client";
'use client';
import { CorrespondenceDetail } from "@/components/correspondences/detail";
import { useCorrespondence } from "@/hooks/use-correspondence";
import { Loader2 } from "lucide-react";
import { useParams } from "next/navigation";
import { CorrespondenceDetail } from '@/components/correspondences/detail';
import { useCorrespondence } from '@/hooks/use-correspondence';
import { Loader2 } from 'lucide-react';
import { useParams } from 'next/navigation';
export default function CorrespondenceDetailPage() {
const params = useParams();
@@ -13,26 +13,26 @@ export default function CorrespondenceDetailPage() {
if (!uuid) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence UUID</h1>
</div>
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence UUID</h1>
</div>
);
}
if (isLoading) {
return (
<div className="flex bg-muted/20 min-h-screen justify-center items-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
<div className="flex bg-muted/20 min-h-screen justify-center items-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
if (isError || !correspondence) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
<p>Please try again later or verify the UUID.</p>
</div>
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
<p>Please try again later or verify the UUID.</p>
</div>
);
}
@@ -1,4 +1,4 @@
import { CorrespondenceForm } from "@/components/correspondences/form";
import { CorrespondenceForm } from '@/components/correspondences/form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -11,9 +11,7 @@ export default function NewCorrespondencePage() {
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">New Correspondence</h1>
<p className="text-muted-foreground mt-1">
Create a new official letter or communication record.
</p>
<p className="text-muted-foreground mt-1">Create a new official letter or communication record.</p>
</div>
<div className="bg-card border rounded-lg p-6 shadow-sm">
@@ -1,10 +1,10 @@
import { Suspense } from "react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Plus, Loader2 } from "lucide-react";
import { CorrespondencesContent } from "@/components/correspondences/correspondences-content";
import { Suspense } from 'react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { Plus, Loader2 } from 'lucide-react';
import { CorrespondencesContent } from '@/components/correspondences/correspondences-content';
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export default function CorrespondencesPage() {
return (
@@ -12,9 +12,7 @@ export default function CorrespondencesPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Correspondences</h1>
<p className="text-muted-foreground mt-1">
Manage official letters and communications
</p>
<p className="text-muted-foreground mt-1">Manage official letters and communications</p>
</div>
<Link href="/correspondences/new">
<Button>
@@ -24,7 +22,13 @@ export default function CorrespondencesPage() {
</Link>
</div>
<Suspense fallback={<div className="flex justify-center py-8"><Loader2 className="h-8 w-8 animate-spin" /></div>}>
<Suspense
fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}
>
<CorrespondencesContent />
</Suspense>
</div>
+7 -9
View File
@@ -1,10 +1,10 @@
"use client";
'use client';
import { StatsCards } from "@/components/dashboard/stats-cards";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { PendingTasks } from "@/components/dashboard/pending-tasks";
import { QuickActions } from "@/components/dashboard/quick-actions";
import { useDashboardStats, useRecentActivity, usePendingTasks } from "@/hooks/use-dashboard";
import { StatsCards } from '@/components/dashboard/stats-cards';
import { RecentActivity } from '@/components/dashboard/recent-activity';
import { PendingTasks } from '@/components/dashboard/pending-tasks';
import { QuickActions } from '@/components/dashboard/quick-actions';
import { useDashboardStats, useRecentActivity, usePendingTasks } from '@/hooks/use-dashboard';
export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useDashboardStats();
@@ -16,9 +16,7 @@ export default function DashboardPage() {
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground mt-1">
Welcome back! Here's an overview of your project status.
</p>
<p className="text-muted-foreground mt-1">Welcome back! Here's an overview of your project status.</p>
</div>
<QuickActions />
</div>
@@ -1,25 +1,25 @@
"use client";
'use client';
import { use, useState } from "react";
import { notFound, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ArrowLeft, Download, FileText, Loader2, Pencil, Upload, X } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { RevisionHistory } from "@/components/drawings/revision-history";
import { format } from "date-fns";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { contractDrawingService } from "@/lib/services/contract-drawing.service";
import { shopDrawingService } from "@/lib/services/shop-drawing.service";
import { asBuiltDrawingService } from "@/lib/services/asbuilt-drawing.service";
import { useUpdateContractDrawing, useUploadRevision } from "@/hooks/use-drawing";
import { use, useState } from 'react';
import { notFound, useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ArrowLeft, Download, FileText, Loader2, Pencil, Upload, X } from 'lucide-react';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { RevisionHistory } from '@/components/drawings/revision-history';
import { format } from 'date-fns';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service';
import { useUpdateContractDrawing, useUploadRevision } from '@/hooks/use-drawing';
type DrawingType = "CONTRACT" | "SHOP" | "AS_BUILT";
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT';
interface FetchedDrawing {
_type: DrawingType;
@@ -31,41 +31,56 @@ interface FetchedDrawing {
createdAt?: string;
updatedAt?: string;
currentRevision?: { title?: string; revisionNumber?: string; legacyDrawingNumber?: string };
revisions?: { revisionId?: number; uuid: string; revisionNumber: string; title?: string; legacyDrawingNumber?: string; revisionDate: string; revisionDescription?: string; revisedByName: string; fileUrl: string; isCurrent: boolean | null; createdBy?: number; updatedBy?: number }[];
revisions?: {
revisionId?: number;
uuid: string;
revisionNumber: string;
title?: string;
legacyDrawingNumber?: string;
revisionDate: string;
revisionDescription?: string;
revisedByName: string;
fileUrl: string;
isCurrent: boolean | null;
createdBy?: number;
updatedBy?: number;
}[];
}
async function fetchDrawingByUuid(uuid: string): Promise<FetchedDrawing | null> {
try {
const result = await contractDrawingService.getByUuid(uuid);
if (result?.data) return { ...result.data, _type: "CONTRACT" as const };
} catch { /* not found */ }
if (result?.data) return { ...result.data, _type: 'CONTRACT' as const };
} catch {
/* not found */
}
try {
const result = await shopDrawingService.getByUuid(uuid);
if (result?.data) return { ...result.data, _type: "SHOP" as const };
} catch { /* not found */ }
if (result?.data) return { ...result.data, _type: 'SHOP' as const };
} catch {
/* not found */
}
try {
const result = await asBuiltDrawingService.getByUuid(uuid);
if (result?.data) return { ...result.data, _type: "AS_BUILT" as const };
} catch { /* not found */ }
if (result?.data) return { ...result.data, _type: 'AS_BUILT' as const };
} catch {
/* not found */
}
return null;
}
export default function DrawingDetailPage({
params,
}: {
params: Promise<{ uuid: string }>;
}) {
export default function DrawingDetailPage({ params }: { params: Promise<{ uuid: string }> }) {
const { uuid } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const isEditMode = searchParams.get("edit") === "true";
const isUploadMode = searchParams.get("upload") === "true";
const isEditMode = searchParams.get('edit') === 'true';
const isUploadMode = searchParams.get('upload') === 'true';
const { data: drawing, isLoading } = useQuery({
queryKey: ["drawing-detail", uuid],
queryKey: ['drawing-detail', uuid],
queryFn: () => fetchDrawingByUuid(uuid),
enabled: !!uuid,
});
@@ -100,8 +115,8 @@ export default function DrawingDetailPage({
);
}
const drawingNumber = drawing.contractDrawingNo || drawing.drawingNumber || "N/A";
const title = drawing.title || drawing.currentRevision?.title || "Untitled";
const drawingNumber = drawing.contractDrawingNo || drawing.drawingNumber || 'N/A';
const title = drawing.title || drawing.currentRevision?.title || 'Untitled';
const revisions = drawing.revisions || [];
return (
@@ -129,7 +144,7 @@ export default function DrawingDetailPage({
Edit Detail
</Link>
</Button>
{drawing._type !== "CONTRACT" && (
{drawing._type !== 'CONTRACT' && (
<Button variant="outline" asChild>
<Link href={`/drawings/${uuid}?upload=true`}>
<Upload className="mr-2 h-4 w-4" />
@@ -155,12 +170,10 @@ export default function DrawingDetailPage({
</div>
{/* Edit Detail Form */}
{isEditMode && (
<EditDetailForm drawing={drawing} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />
)}
{isEditMode && <EditDetailForm drawing={drawing} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />}
{/* Upload Revision Form */}
{isUploadMode && drawing._type !== "CONTRACT" && (
{isUploadMode && drawing._type !== 'CONTRACT' && (
<UploadRevisionForm drawingType={drawing._type} uuid={uuid} onDone={() => router.push(`/drawings/${uuid}`)} />
)}
@@ -194,7 +207,7 @@ export default function DrawingDetailPage({
<div>
<p className="text-sm font-medium text-muted-foreground">Created</p>
<p className="font-medium mt-1">
{drawing.createdAt ? format(new Date(drawing.createdAt), "dd MMM yyyy") : "N/A"}
{drawing.createdAt ? format(new Date(drawing.createdAt), 'dd MMM yyyy') : 'N/A'}
</p>
</div>
</div>
@@ -224,36 +237,28 @@ export default function DrawingDetailPage({
}
/* ─── Edit Detail Form ─── */
function EditDetailForm({
drawing,
uuid,
onDone,
}: {
drawing: FetchedDrawing;
uuid: string;
onDone: () => void;
}) {
function EditDetailForm({ drawing, uuid, onDone }: { drawing: FetchedDrawing; uuid: string; onDone: () => void }) {
const updateMutation = useUpdateContractDrawing();
const queryClient = useQueryClient();
const [formTitle, setFormTitle] = useState(drawing.title || drawing.currentRevision?.title || "");
const [formDrawingNo, setFormDrawingNo] = useState(drawing.contractDrawingNo || drawing.drawingNumber || "");
const [formVolumePage, setFormVolumePage] = useState(drawing.volumePage?.toString() || "");
const [formTitle, setFormTitle] = useState(drawing.title || drawing.currentRevision?.title || '');
const [formDrawingNo, setFormDrawingNo] = useState(drawing.contractDrawingNo || drawing.drawingNumber || '');
const [formVolumePage, setFormVolumePage] = useState(drawing.volumePage?.toString() || '');
const handleSave = () => {
if (drawing._type === "CONTRACT") {
if (drawing._type === 'CONTRACT') {
updateMutation.mutate(
{
uuid,
data: {
title: formTitle,
contractDrawingNo: formDrawingNo,
volumePage: formVolumePage ? parseInt(formVolumePage, 10) : undefined,
volumePage: formVolumePage ? Number(formVolumePage) : undefined,
},
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drawing-detail", uuid] });
queryClient.invalidateQueries({ queryKey: ['drawing-detail', uuid] });
onDone();
},
}
@@ -273,7 +278,7 @@ function EditDetailForm({
<Label>Title</Label>
<Input value={formTitle} onChange={(e) => setFormTitle(e.target.value)} />
</div>
{drawing._type === "CONTRACT" && (
{drawing._type === 'CONTRACT' && (
<div>
<Label>Volume Page</Label>
<Input type="number" value={formVolumePage} onChange={(e) => setFormVolumePage(e.target.value)} />
@@ -306,10 +311,10 @@ function UploadRevisionForm({
const uploadMutation = useUploadRevision(drawingType);
const queryClient = useQueryClient();
const [revisionLabel, setRevisionLabel] = useState("");
const [revTitle, setRevTitle] = useState("");
const [description, setDescription] = useState("");
const [legacyNo, setLegacyNo] = useState("");
const [revisionLabel, setRevisionLabel] = useState('');
const [revTitle, setRevTitle] = useState('');
const [description, setDescription] = useState('');
const [legacyNo, setLegacyNo] = useState('');
const handleUpload = () => {
if (!revisionLabel || !revTitle) return;
@@ -327,7 +332,7 @@ function UploadRevisionForm({
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["drawing-detail", uuid] });
queryClient.invalidateQueries({ queryKey: ['drawing-detail', uuid] });
onDone();
},
}
@@ -341,7 +346,11 @@ function UploadRevisionForm({
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Revision Label *</Label>
<Input placeholder="e.g. A, B, 1, 2" value={revisionLabel} onChange={(e) => setRevisionLabel(e.target.value)} />
<Input
placeholder="e.g. A, B, 1, 2"
value={revisionLabel}
onChange={(e) => setRevisionLabel(e.target.value)}
/>
</div>
<div>
<Label>Legacy Drawing No.</Label>
@@ -354,7 +363,12 @@ function UploadRevisionForm({
</div>
<div>
<Label>Description</Label>
<Textarea placeholder="What changed in this revision?" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
<Textarea
placeholder="What changed in this revision?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="flex gap-3 pt-2">
<Button onClick={handleUpload} disabled={uploadMutation.isPending || !revisionLabel || !revTitle}>
+41 -53
View File
@@ -1,19 +1,13 @@
"use client";
'use client';
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DrawingList } from "@/components/drawings/list";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Upload, Loader2 } from "lucide-react";
import Link from "next/link";
import { useProjects } from "@/hooks/use-master-data";
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DrawingList } from '@/components/drawings/list';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Upload, Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useProjects } from '@/hooks/use-master-data';
export default function DrawingsPage() {
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string | undefined>(undefined);
@@ -24,9 +18,7 @@ export default function DrawingsPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Drawings</h1>
<p className="text-muted-foreground mt-1">
Manage contract, shop, and as-built drawings
</p>
<p className="text-muted-foreground mt-1">Manage contract, shop, and as-built drawings</p>
</div>
<Link href="/drawings/upload">
<Button>
@@ -39,10 +31,7 @@ export default function DrawingsPage() {
{/* Project Selector */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Project:</span>
<Select
value={selectedProjectUuid ?? ""}
onValueChange={(v) => setSelectedProjectUuid(v || undefined)}
>
<Select value={selectedProjectUuid ?? ''} onValueChange={(v) => setSelectedProjectUuid(v || undefined)}>
<SelectTrigger className="w-[300px]">
{isLoadingProjects ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -72,43 +61,42 @@ export default function DrawingsPage() {
}
function DrawingTabs({ projectUuid }: { projectUuid: string }) {
const [search, setSearch] = useState("");
const [search, setSearch] = useState('');
// We can add more specific filters here (e.g. category) later
return (
<Tabs defaultValue="contract" className="w-full">
<div className="flex justify-between items-center mb-6">
<TabsList className="grid w-full grid-cols-3 max-w-[400px]">
<TabsTrigger value="contract">Contract</TabsTrigger>
<TabsTrigger value="shop">Shop</TabsTrigger>
<TabsTrigger value="asbuilt">As Built</TabsTrigger>
</TabsList>
<Tabs defaultValue="contract" className="w-full">
<div className="flex justify-between items-center mb-6">
<TabsList className="grid w-full grid-cols-3 max-w-[400px]">
<TabsTrigger value="contract">Contract</TabsTrigger>
<TabsTrigger value="shop">Shop</TabsTrigger>
<TabsTrigger value="asbuilt">As Built</TabsTrigger>
</TabsList>
<div className="flex gap-2">
<div className="relative">
<input
type="text"
placeholder="Search drawings..."
className="h-10 w-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2">
<div className="relative">
<input
type="text"
placeholder="Search drawings..."
className="h-10 w-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
</div>
<TabsContent value="contract" className="mt-0">
<DrawingList type="CONTRACT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="contract" className="mt-0">
<DrawingList type="CONTRACT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="shop" className="mt-0">
<DrawingList type="SHOP" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="shop" className="mt-0">
<DrawingList type="SHOP" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
<TabsContent value="asbuilt" className="mt-0">
<DrawingList type="AS_BUILT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
</Tabs>
)
<TabsContent value="asbuilt" className="mt-0">
<DrawingList type="AS_BUILT" projectUuid={projectUuid} filters={{ search }} />
</TabsContent>
</Tabs>
);
}
@@ -1,4 +1,4 @@
import { DrawingUploadForm } from "@/components/drawings/upload-form";
import { DrawingUploadForm } from '@/components/drawings/upload-form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -11,9 +11,7 @@ export default function DrawingUploadPage() {
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">Upload Drawing</h1>
<p className="text-muted-foreground mt-1">
Upload a new contract or shop drawing revision.
</p>
<p className="text-muted-foreground mt-1">Upload a new contract or shop drawing revision.</p>
</div>
<DrawingUploadForm />
+3 -11
View File
@@ -5,15 +5,9 @@ import { Button } from '@/components/ui/button';
import { AlertCircle, RefreshCw } from 'lucide-react';
import Link from 'next/link';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error('[Dashboard Error Boundary]', error);
// // console.error('[Dashboard Error Boundary]', error); /* TODO: Remove before prod */
}, [error]);
return (
@@ -24,9 +18,7 @@ export default function DashboardError({
<p className="text-muted-foreground mt-1 text-sm max-w-md">
{error.message || 'An error occurred while loading this page.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
)}
{error.digest && <p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>}
</div>
<div className="flex gap-3">
<Button onClick={reset} variant="outline" size="sm">
+5 -11
View File
@@ -1,24 +1,18 @@
import { Header } from "@/components/layout/header";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
// Force dynamic rendering for all pages under (dashboard) route group.
// QNAP overlayfs cannot handle the .segments/!<base64> directories
// that Next.js 16 creates during static page generation.
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-background">
<Sidebar />
<div className="flex-1 flex flex-col min-h-screen overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6 bg-muted/10">
{children}
</main>
<main className="flex-1 overflow-y-auto p-6 bg-muted/10">{children}</main>
</div>
</div>
);
+43 -84
View File
@@ -1,29 +1,22 @@
// File: app/(dashboard)/profile/page.tsx
"use client";
'use client';
import { useState } from "react";
import { useSession } from "next-auth/react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Loader2, User, Shield, Bell } from "lucide-react";
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2, User, Shield, Bell } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Switch } from "@/components/ui/switch";
import apiClient from "@/lib/api/client";
import { toast } from "sonner";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Switch } from '@/components/ui/switch';
import apiClient from '@/lib/api/client';
import { toast } from 'sonner';
// -----------------------------------------------------------------------------
// Schemas
@@ -31,13 +24,13 @@ import { toast } from "sonner";
const passwordSchema = z
.object({
currentPassword: z.string().min(1, "กรุณาระบุรหัสผ่านปัจจุบัน"),
newPassword: z.string().min(8, "รหัสผ่านใหม่ต้องมีอย่างน้อย 8 ตัวอักษร"),
confirmPassword: z.string().min(1, "กรุณายืนยันรหัสผ่านใหม่"),
currentPassword: z.string().min(1, 'กรุณาระบุรหัสผ่านปัจจุบัน'),
newPassword: z.string().min(8, 'รหัสผ่านใหม่ต้องมีอย่างน้อย 8 ตัวอักษร'),
confirmPassword: z.string().min(1, 'กรุณายืนยันรหัสผ่านใหม่'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "รหัสผ่านใหม่ไม่ตรงกัน",
path: ["confirmPassword"],
message: 'รหัสผ่านใหม่ไม่ตรงกัน',
path: ['confirmPassword'],
});
type PasswordValues = z.infer<typeof passwordSchema>;
@@ -60,14 +53,14 @@ export default function ProfilePage() {
setIsLoading(true);
try {
// เรียก API เปลี่ยนรหัสผ่าน
await apiClient.put("/users/change-password", {
await apiClient.put('/users/change-password', {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
toast.success('เปลี่ยนรหัสผ่านสำเร็จ');
reset();
} catch (error) {
} catch (_error) {
toast.error('ไม่สามารถเปลี่ยนรหัสผ่านได้: รหัสผ่านปัจจุบันไม่ถูกต้อง');
// Password change failed - toast shown
} finally {
@@ -82,11 +75,11 @@ export default function ProfilePage() {
const [digestMode, setDigestMode] = useState(false);
// Helper to get initials
const userName = session?.user?.name || "User";
const userName = session?.user?.name || 'User';
const userInitials = userName
.split(" ")
.split(' ')
.map((n) => n[0])
.join("")
.join('')
.toUpperCase()
.substring(0, 2);
@@ -94,9 +87,7 @@ export default function ProfilePage() {
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Profile & Settings</h3>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground"></p>
</div>
<Tabs defaultValue="general" className="space-y-4">
@@ -120,21 +111,19 @@ export default function ProfilePage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={session?.user?.image || ""} />
<AvatarImage src={session?.user?.image || ''} />
<AvatarFallback className="text-lg">{userInitials}</AvatarFallback>
</Avatar>
<div>
<h4 className="text-lg font-semibold">{userName}</h4>
<p className="text-sm text-muted-foreground">{session?.user?.email}</p>
<div className="mt-2 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground hover:bg-primary/80">
{session?.user?.role || "Member"}
{session?.user?.role || 'Member'}
</div>
</div>
</div>
@@ -142,20 +131,20 @@ export default function ProfilePage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Input defaultValue={userName.split(" ")[0]} disabled />
<Input defaultValue={userName.split(' ')[0]} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input defaultValue={userName.split(" ")[1] || ""} disabled />
<Input defaultValue={userName.split(' ')[1] || ''} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input defaultValue={session?.user?.email || ""} disabled />
<Input defaultValue={session?.user?.email || ''} disabled />
</div>
<div className="space-y-2">
<Label> / </Label>
{/* ในอนาคตดึงจาก Organization ID */}
<Input defaultValue={`Organization ID: ${session?.user?.organizationId || "-"}`} disabled />
<Input defaultValue={`Organization ID: ${session?.user?.organizationId || '-'}`} disabled />
</div>
</div>
</CardContent>
@@ -171,41 +160,25 @@ export default function ProfilePage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
( 8 )
</CardDescription>
<CardDescription> ( 8 )</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onPasswordSubmit)}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword"></Label>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
/>
<Input id="currentPassword" type="password" {...register('currentPassword')} />
{errors.currentPassword && (
<p className="text-xs text-destructive">{errors.currentPassword.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="newPassword"></Label>
<Input
id="newPassword"
type="password"
{...register("newPassword")}
/>
{errors.newPassword && (
<p className="text-xs text-destructive">{errors.newPassword.message}</p>
)}
<Input id="newPassword" type="password" {...register('newPassword')} />
{errors.newPassword && <p className="text-xs text-destructive">{errors.newPassword.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
<Input id="confirmPassword" type="password" {...register('confirmPassword')} />
{errors.confirmPassword && (
<p className="text-xs text-destructive">{errors.confirmPassword.message}</p>
)}
@@ -226,9 +199,7 @@ export default function ProfilePage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between space-x-2">
@@ -238,11 +209,7 @@ export default function ProfilePage() {
</span>
</Label>
<Switch
id="notify-email"
checked={notifyEmail}
onCheckedChange={setNotifyEmail}
/>
<Switch id="notify-email" checked={notifyEmail} onCheckedChange={setNotifyEmail} />
</div>
<div className="flex items-center justify-between space-x-2">
@@ -252,11 +219,7 @@ export default function ProfilePage() {
LINE Official Account
</span>
</Label>
<Switch
id="notify-line"
checked={notifyLine}
onCheckedChange={setNotifyLine}
/>
<Switch id="notify-line" checked={notifyLine} onCheckedChange={setNotifyLine} />
</div>
<div className="flex items-center justify-between space-x-2">
@@ -266,11 +229,7 @@ export default function ProfilePage() {
( Spam)
</span>
</Label>
<Switch
id="digest-mode"
checked={digestMode}
onCheckedChange={setDigestMode}
/>
<Switch id="digest-mode" checked={digestMode} onCheckedChange={setDigestMode} />
</div>
</CardContent>
<CardFooter>
+38 -80
View File
@@ -1,33 +1,20 @@
// File: app/(dashboard)/projects/new/page.tsx
"use client";
'use client';
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Loader2, ChevronLeft, Save } from "lucide-react";
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Loader2, ChevronLeft, Save } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { toast } from "sonner";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -40,15 +27,12 @@ export const fetchCache = 'force-no-store';
const projectSchema = z.object({
projectCode: z
.string()
.min(1, "กรุณาระบุรหัสโครงการ")
.max(50, "รหัสโครงการต้องไม่เกิน 50 ตัวอักษร")
.regex(/^[A-Z0-9-]+$/, "รหัสโครงการควรประกอบด้วยตัวอักษรภาษาอังกฤษตัวใหญ่ ตัวเลข หรือขีด (-) เท่านั้น"),
projectName: z
.string()
.min(1, "กรุณาระบุชื่อโครงการ")
.max(255, "ชื่อโครงการต้องไม่เกิน 255 ตัวอักษร"),
.min(1, 'กรุณาระบุรหัสโครงการ')
.max(50, 'รหัสโครงการต้องไม่เกิน 50 ตัวอักษร')
.regex(/^[A-Z0-9-]+$/, 'รหัสโครงการควรประกอบด้วยตัวอักษรภาษาอังกฤษตัวใหญ่ ตัวเลข หรือขีด (-) เท่านั้น'),
projectName: z.string().min(1, 'กรุณาระบุชื่อโครงการ').max(255, 'ชื่อโครงการต้องไม่เกิน 255 ตัวอักษร'),
description: z.string().optional(),
status: z.enum(["Active", "Inactive", "On Hold"]),
status: z.enum(['Active', 'Inactive', 'On Hold']),
startDate: z.string().optional(),
endDate: z.string().optional(),
});
@@ -68,14 +52,14 @@ export default function CreateProjectPage() {
} = useForm<ProjectValues>({
resolver: zodResolver(projectSchema),
defaultValues: {
projectCode: "",
projectName: "",
status: "Active",
projectCode: '',
projectName: '',
status: 'Active',
},
});
// 3. ฟังก์ชัน Submit
async function onSubmit(data: ProjectValues) {
async function onSubmit(_data: ProjectValues) {
setIsLoading(true);
try {
// เรียก API สร้างโครงการ (Mockup URL)
@@ -89,7 +73,7 @@ export default function CreateProjectPage() {
toast.success('สร้างโครงการสำเร็จ');
router.push('/projects');
router.refresh();
} catch (error) {
} catch (_error) {
toast.error('เกิดข้อผิดพลาดในการสร้างโครงการ');
// Project creation failed - toast shown
} finally {
@@ -101,20 +85,13 @@ export default function CreateProjectPage() {
<div className="max-w-2xl mx-auto space-y-6">
{/* Header with Back Button */}
<div className="flex items-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => router.back()}
className="h-9 w-9"
>
<Button variant="outline" size="icon" onClick={() => router.back()} className="h-9 w-9">
<ChevronLeft className="h-5 w-5" />
<span className="sr-only">Back</span>
</Button>
<div>
<h2 className="text-2xl font-bold tracking-tight">Create New Project</h2>
<p className="text-muted-foreground">
</p>
<p className="text-muted-foreground"></p>
</div>
</div>
@@ -122,9 +99,7 @@ export default function CreateProjectPage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -136,19 +111,17 @@ export default function CreateProjectPage() {
<Input
id="project_code"
placeholder="e.g. LCBP3-C1"
className={errors.projectCode ? "border-destructive" : ""}
{...register("projectCode")}
className={errors.projectCode ? 'border-destructive' : ''}
{...register('projectCode')}
onChange={(e) => {
e.target.value = e.target.value.toUpperCase();
register("projectCode").onChange(e);
register('projectCode').onChange(e);
}}
/>
{errors.projectCode ? (
<p className="text-xs text-destructive">{errors.projectCode.message}</p>
) : (
<p className="text-xs text-muted-foreground">
(-)
</p>
<p className="text-xs text-muted-foreground"> (-) </p>
)}
</div>
@@ -160,12 +133,10 @@ export default function CreateProjectPage() {
<Input
id="project_name"
placeholder="ระบุชื่อโครงการฉบับเต็ม..."
className={errors.projectName ? "border-destructive" : ""}
{...register("projectName")}
className={errors.projectName ? 'border-destructive' : ''}
{...register('projectName')}
/>
{errors.projectName && (
<p className="text-xs text-destructive">{errors.projectName.message}</p>
)}
{errors.projectName && <p className="text-xs text-destructive">{errors.projectName.message}</p>}
</div>
{/* Description */}
@@ -175,7 +146,7 @@ export default function CreateProjectPage() {
id="description"
placeholder="คำอธิบายเกี่ยวกับขอบเขตงานของโครงการ..."
className="min-h-[100px]"
{...register("description")}
{...register('description')}
/>
</div>
@@ -183,19 +154,11 @@ export default function CreateProjectPage() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="start_date"></Label>
<Input
id="start_date"
type="date"
{...register("startDate")}
/>
<Input id="start_date" type="date" {...register('startDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="end_date"></Label>
<Input
id="end_date"
type="date"
{...register("endDate")}
/>
<Input id="end_date" type="date" {...register('endDate')} />
</div>
</div>
@@ -222,12 +185,7 @@ export default function CreateProjectPage() {
</CardContent>
<CardFooter className="flex justify-end gap-2 border-t p-4 bg-muted/50">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={isLoading}
>
<Button type="button" variant="ghost" onClick={() => router.back()} disabled={isLoading}>
</Button>
<Button type="submit" disabled={isLoading}>
+79 -71
View File
@@ -1,22 +1,15 @@
// File: app/(dashboard)/projects/page.tsx
"use client";
'use client';
import { useState } from "react";
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from 'react';
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,22 +17,15 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
} from '@/components/ui/dropdown-menu';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
// Type สำหรับข้อมูล Project (Mockup ตาม Data Dictionary)
interface Project {
id: number;
projectCode: string;
projectName: string;
status: "Active" | "Completed" | "On Hold";
status: 'Active' | 'Completed' | 'On Hold';
progress: number;
startDate: string;
endDate: string;
@@ -50,56 +36,61 @@ interface Project {
const mockProjects: Project[] = [
{
id: 1,
projectCode: "LCBP3",
projectName: "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)",
status: "Active",
projectCode: 'LCBP3',
projectName: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)',
status: 'Active',
progress: 45,
startDate: "2021-01-01",
endDate: "2025-12-31",
contractorName: "Multiple Contractors",
startDate: '2021-01-01',
endDate: '2025-12-31',
contractorName: 'Multiple Contractors',
},
{
id: 2,
projectCode: "LCBP3-C1",
projectName: "งานก่อสร้างงานทางทะเล (ส่วนที่ 1)",
status: "Active",
projectCode: 'LCBP3-C1',
projectName: 'งานก่อสร้างงานทางทะเล (ส่วนที่ 1)',
status: 'Active',
progress: 70,
startDate: "2021-06-01",
endDate: "2024-06-01",
contractorName: "CNNC",
startDate: '2021-06-01',
endDate: '2024-06-01',
contractorName: 'CNNC',
},
{
id: 3,
projectCode: "LCBP3-C2",
projectName: "งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)",
status: "Active",
projectCode: 'LCBP3-C2',
projectName: 'งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)',
status: 'Active',
progress: 15,
startDate: "2023-01-01",
endDate: "2026-01-01",
contractorName: "ITD-NWR Joint Venture",
startDate: '2023-01-01',
endDate: '2026-01-01',
contractorName: 'ITD-NWR Joint Venture',
},
];
export default function ProjectsPage() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [searchTerm, setSearchTerm] = useState('');
const filteredProjects = mockProjects.filter((project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
const filteredProjects = mockProjects.filter(
(project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusVariant = (status: string) => {
switch (status) {
case "Active": return "success"; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case "Completed": return "default";
case "On Hold": return "warning";
default: return "secondary";
case 'Active':
return 'success'; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case 'Completed':
return 'default';
case 'On Hold':
return 'warning';
default:
return 'secondary';
}
};
const handleCreateProject = () => {
router.push("/projects/new"); // อัปเดตเป็นลิงก์จริง
router.push('/projects/new'); // อัปเดตเป็นลิงก์จริง
};
const handleViewDetails = (id: number) => {
@@ -112,9 +103,7 @@ export default function ProjectsPage() {
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Projects</h2>
<p className="text-muted-foreground">
</p>
<p className="text-muted-foreground"> </p>
</div>
<Button onClick={handleCreateProject} className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" /> New Project
@@ -149,7 +138,11 @@ export default function ProjectsPage() {
</TableHeader>
<TableBody>
{filteredProjects.map((project) => (
<TableRow key={project.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleViewDetails(project.id)}>
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetails(project.id)}
>
<TableCell className="font-medium">{project.projectCode}</TableCell>
<TableCell>
<div className="flex flex-col">
@@ -161,16 +154,12 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>{project.contractorName}</TableCell>
<TableCell>
<Badge variant={getStatusVariant(project.status)}>
{project.status}
</Badge>
<Badge variant={getStatusVariant(project.status)}>{project.status}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={project.progress} className="h-2" />
<span className="text-xs text-muted-foreground w-[30px] text-right">
{project.progress}%
</span>
<span className="text-xs text-muted-foreground w-[30px] text-right">{project.progress}%</span>
</div>
</TableCell>
<TableCell className="text-right">
@@ -183,14 +172,29 @@ export default function ProjectsPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(project.id); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleViewDetails(project.id);
}}
>
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Manage Contracts for ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Manage Contracts for ${project.projectCode}`);
}}
>
Manage Contracts
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Edit ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Edit ${project.projectCode}`);
}}
>
Edit Project
</DropdownMenuItem>
</DropdownMenuContent>
@@ -205,14 +209,16 @@ export default function ProjectsPage() {
{/* Mobile View: Cards */}
<div className="grid gap-4 md:hidden">
{filteredProjects.map((project) => (
<Card key={project.id} onClick={() => handleViewDetails(project.id)} className="cursor-pointer active:bg-muted/50">
<Card
key={project.id}
onClick={() => handleViewDetails(project.id)}
className="cursor-pointer active:bg-muted/50"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-base font-bold">{project.projectCode}</CardTitle>
<CardDescription className="mt-1 line-clamp-2">
{project.projectName}
</CardDescription>
<CardDescription className="mt-1 line-clamp-2">{project.projectName}</CardDescription>
</div>
<Badge variant={getStatusVariant(project.status)} className="shrink-0">
{project.status}
@@ -226,7 +232,9 @@ export default function ProjectsPage() {
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>{project.startDate} - {project.endDate}</span>
<span>
{project.startDate} - {project.endDate}
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs">
@@ -1,22 +1,15 @@
// File: app/(dashboard)/projects/page.tsx
"use client";
'use client';
import { useState } from "react";
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from 'react';
import { Plus, Search, MoreHorizontal, Folder, Calendar, BarChart3 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,22 +17,15 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
} from '@/components/ui/dropdown-menu';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
// Type สำหรับข้อมูล Project (Mockup ตาม Data Dictionary)
interface Project {
id: number;
projectCode: string;
projectName: string;
status: "Active" | "Completed" | "On Hold";
status: 'Active' | 'Completed' | 'On Hold';
progress: number;
startDate: string;
endDate: string;
@@ -50,56 +36,61 @@ interface Project {
const mockProjects: Project[] = [
{
id: 1,
projectCode: "LCBP3",
projectName: "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)",
status: "Active",
projectCode: 'LCBP3',
projectName: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)',
status: 'Active',
progress: 45,
startDate: "2021-01-01",
endDate: "2025-12-31",
contractorName: "Multiple Contractors",
startDate: '2021-01-01',
endDate: '2025-12-31',
contractorName: 'Multiple Contractors',
},
{
id: 2,
projectCode: "LCBP3-C1",
projectName: "งานก่อสร้างงานทางทะเล (ส่วนที่ 1)",
status: "Active",
projectCode: 'LCBP3-C1',
projectName: 'งานก่อสร้างงานทางทะเล (ส่วนที่ 1)',
status: 'Active',
progress: 70,
startDate: "2021-06-01",
endDate: "2024-06-01",
contractorName: "CNNC",
startDate: '2021-06-01',
endDate: '2024-06-01',
contractorName: 'CNNC',
},
{
id: 3,
projectCode: "LCBP3-C2",
projectName: "งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)",
status: "Active",
projectCode: 'LCBP3-C2',
projectName: 'งานก่อสร้างอาคาร ท่าเทียบเรือ (ส่วนที่ 2)',
status: 'Active',
progress: 15,
startDate: "2023-01-01",
endDate: "2026-01-01",
contractorName: "ITD-NWR Joint Venture",
startDate: '2023-01-01',
endDate: '2026-01-01',
contractorName: 'ITD-NWR Joint Venture',
},
];
export default function ProjectsPage() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [searchTerm, setSearchTerm] = useState('');
const filteredProjects = mockProjects.filter((project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
const filteredProjects = mockProjects.filter(
(project) =>
project.projectName.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.projectCode.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusVariant = (status: string) => {
switch (status) {
case "Active": return "success"; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case "Completed": return "default";
case "On Hold": return "warning";
default: return "secondary";
case 'Active':
return 'success'; // ใช้ variant ที่เรา custom ไว้ใน badge.tsx
case 'Completed':
return 'default';
case 'On Hold':
return 'warning';
default:
return 'secondary';
}
};
const handleCreateProject = () => {
router.push("/projects/new"); // อัปเดตเป็นลิงก์จริง
router.push('/projects/new'); // อัปเดตเป็นลิงก์จริง
};
const handleViewDetails = (id: number) => {
@@ -112,9 +103,7 @@ export default function ProjectsPage() {
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Projects</h2>
<p className="text-muted-foreground">
</p>
<p className="text-muted-foreground"> </p>
</div>
<Button onClick={handleCreateProject} className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" /> New Project
@@ -149,7 +138,11 @@ export default function ProjectsPage() {
</TableHeader>
<TableBody>
{filteredProjects.map((project) => (
<TableRow key={project.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleViewDetails(project.id)}>
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetails(project.id)}
>
<TableCell className="font-medium">{project.projectCode}</TableCell>
<TableCell>
<div className="flex flex-col">
@@ -161,16 +154,12 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>{project.contractorName}</TableCell>
<TableCell>
<Badge variant={getStatusVariant(project.status)}>
{project.status}
</Badge>
<Badge variant={getStatusVariant(project.status)}>{project.status}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={project.progress} className="h-2" />
<span className="text-xs text-muted-foreground w-[30px] text-right">
{project.progress}%
</span>
<span className="text-xs text-muted-foreground w-[30px] text-right">{project.progress}%</span>
</div>
</TableCell>
<TableCell className="text-right">
@@ -183,14 +172,29 @@ export default function ProjectsPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(project.id); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleViewDetails(project.id);
}}
>
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Manage Contracts for ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Manage Contracts for ${project.projectCode}`);
}}
>
Manage Contracts
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); alert(`Edit ${project.projectCode}`); }}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
alert(`Edit ${project.projectCode}`);
}}
>
Edit Project
</DropdownMenuItem>
</DropdownMenuContent>
@@ -205,14 +209,16 @@ export default function ProjectsPage() {
{/* Mobile View: Cards */}
<div className="grid gap-4 md:hidden">
{filteredProjects.map((project) => (
<Card key={project.id} onClick={() => handleViewDetails(project.id)} className="cursor-pointer active:bg-muted/50">
<Card
key={project.id}
onClick={() => handleViewDetails(project.id)}
className="cursor-pointer active:bg-muted/50"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-base font-bold">{project.projectCode}</CardTitle>
<CardDescription className="mt-1 line-clamp-2">
{project.projectName}
</CardDescription>
<CardDescription className="mt-1 line-clamp-2">{project.projectName}</CardDescription>
</div>
<Badge variant={getStatusVariant(project.status)} className="shrink-0">
{project.status}
@@ -226,7 +232,9 @@ export default function ProjectsPage() {
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>{project.startDate} - {project.endDate}</span>
<span>
{project.startDate} - {project.endDate}
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs">
+6 -10
View File
@@ -1,9 +1,9 @@
"use client";
'use client';
import { RFADetail } from "@/components/rfas/detail";
import { notFound, useParams } from "next/navigation";
import { useRFA } from "@/hooks/use-rfa";
import { Loader2 } from "lucide-react";
import { RFADetail } from '@/components/rfas/detail';
import { notFound, useParams } from 'next/navigation';
import { useRFA } from '@/hooks/use-rfa';
import { Loader2 } from 'lucide-react';
export default function RFADetailPage() {
const { uuid } = useParams();
@@ -22,11 +22,7 @@ export default function RFADetailPage() {
if (isError || !rfa) {
// Check if error is 404
return (
<div className="text-center py-20 text-red-500">
RFA not found or failed to load.
</div>
);
return <div className="text-center py-20 text-red-500">RFA not found or failed to load.</div>;
}
return <RFADetail data={rfa} />;
+2 -4
View File
@@ -1,4 +1,4 @@
import { RFAForm } from "@/components/rfas/form";
import { RFAForm } from '@/components/rfas/form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -11,9 +11,7 @@ export default function NewRFAPage() {
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">New RFA</h1>
<p className="text-muted-foreground mt-1">
Create a new Request for Approval.
</p>
<p className="text-muted-foreground mt-1">Create a new Request for Approval.</p>
</div>
<RFAForm />
+23 -28
View File
@@ -1,4 +1,4 @@
"use client";
'use client';
import { RFAList } from '@/components/rfas/list';
import { Button } from '@/components/ui/button';
@@ -11,8 +11,8 @@ import { Suspense } from 'react';
function RFAsContent() {
const searchParams = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');
const statusId = searchParams.get('status') ? parseInt(searchParams.get('status')!) : undefined;
const page = Number(searchParams.get('page') || '1');
const statusId = searchParams.get('status') ? Number(searchParams.get('status')!) : undefined;
const search = searchParams.get('search') || undefined;
const projectId = searchParams.get('projectId') || undefined; // ADR-019: Pass UUID string directly
@@ -20,24 +20,21 @@ function RFAsContent() {
const { data, isLoading, isError } = useRFAs({ page, statusId, search, projectId, revisionStatus });
return (
<>
<div className="mb-4 flex gap-2">
{/* Simple Filter Buttons using standard Buttons for now, or use a Select if imported */}
<div className="flex gap-1 bg-muted p-1 rounded-md">
{['ALL', 'CURRENT', 'OLD'].map((status) => (
<Link key={status} href={`?${new URLSearchParams({...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1'}).toString()}`}>
<Button
variant={revisionStatus === status ? 'default' : 'ghost'}
size="sm"
className="text-xs px-3"
>
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
</Button>
</Link>
))}
{['ALL', 'CURRENT', 'OLD'].map((status) => (
<Link
key={status}
href={`?${new URLSearchParams({ ...Object.fromEntries(searchParams.entries()), revisionStatus: status, page: '1' }).toString()}`}
>
<Button variant={revisionStatus === status ? 'default' : 'ghost'} size="sm" className="text-xs px-3">
{status === 'CURRENT' ? 'Latest' : status === 'OLD' ? 'Previous' : 'All'}
</Button>
</Link>
))}
</div>
</div>
@@ -46,13 +43,11 @@ function RFAsContent() {
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : isError ? (
<div className="text-red-500 text-center py-8">
Failed to load RFAs.
</div>
<div className="text-red-500 text-center py-8">Failed to load RFAs.</div>
) : (
<>
<RFAList data={data?.data || []} />
<div className="mt-4">
<div className="mt-4">
<Pagination
currentPage={data?.meta?.page || 1}
totalPages={data?.meta?.totalPages || 1}
@@ -71,9 +66,7 @@ export default function RFAsPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">RFAs (Request for Approval)</h1>
<p className="text-muted-foreground mt-1">
Manage approval requests and submissions
</p>
<p className="text-muted-foreground mt-1">Manage approval requests and submissions</p>
</div>
<Link href="/rfas/new">
<Button>
@@ -83,11 +76,13 @@ export default function RFAsPage() {
</Link>
</div>
<Suspense fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}>
<Suspense
fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}
>
<RFAsContent />
</Suspense>
</div>
+20 -19
View File
@@ -1,20 +1,20 @@
"use client";
'use client';
import { useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { SearchFilters } from "@/components/search/filters";
import { SearchResults } from "@/components/search/results";
import { SearchFilters as FilterType } from "@/types/search";
import { useSearch } from "@/hooks/use-search";
import { Loader2 } from "lucide-react";
import { useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { SearchFilters } from '@/components/search/filters';
import { SearchResults } from '@/components/search/results';
import { SearchFilters as FilterType } from '@/types/search';
import { useSearch } from '@/hooks/use-search';
import { Loader2 } from 'lucide-react';
function SearchContent() {
const searchParams = useSearchParams();
// URL Params state
const query = searchParams.get("q") || "";
const typeParam = searchParams.get("type");
const statusParam = searchParams.get("status");
const query = searchParams.get('q') || '';
const typeParam = searchParams.get('type');
const statusParam = searchParams.get('status');
// Local Filter State (synced with URL initially, but can be independent before apply)
// For simplicity, we'll keep filters in sync with valid search params or local state that pushes to URL
@@ -43,9 +43,8 @@ function SearchContent() {
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-muted-foreground mt-1">
{isLoading
? "Searching..."
: `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"`
}
? 'Searching...'
: `Found ${results?.meta?.total ?? results?.data?.length ?? 0} results for "${query}"`}
</p>
</div>
@@ -69,11 +68,13 @@ function SearchContent() {
export default function SearchPage() {
return (
<div className="space-y-6">
<Suspense fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}>
<Suspense
fallback={
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}
>
<SearchContent />
</Suspense>
</div>
@@ -1,37 +1,34 @@
"use client";
'use client';
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { transmittalService } from "@/lib/services/transmittal.service";
import { Transmittal } from "@/types/transmittal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowLeft, RefreshCw, Printer } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { transmittalService } from '@/lib/services/transmittal.service';
import { Transmittal } from '@/types/transmittal';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { ArrowLeft, RefreshCw, Printer } from 'lucide-react';
import Link from 'next/link';
import { format } from 'date-fns';
import { toast } from 'sonner';
export default function TransmittalDetailPage() {
const params = useParams();
const uuid = params.uuid as string;
const { data: transmittal, isLoading, error } = useQuery<Transmittal>({
queryKey: ["transmittal", uuid],
const {
data: transmittal,
isLoading,
error,
} = useQuery<Transmittal>({
queryKey: ['transmittal', uuid],
queryFn: () => transmittalService.getByUuid(uuid),
enabled: !!uuid,
});
const handlePrint = () => {
toast.info("PDF Export is coming soon...");
toast.info('PDF Export is coming soon...');
// TODO: Implement PDF download
};
@@ -74,7 +71,7 @@ export default function TransmittalDetailPage() {
{transmittal.correspondence?.correspondenceNumber || transmittal.transmittalNo}
</h1>
<p className="text-muted-foreground">
{transmittal.correspondence?.revisions?.find(r => r.isCurrent)?.title || transmittal.subject}
{transmittal.correspondence?.revisions?.find((r) => r.isCurrent)?.title || transmittal.subject}
</p>
</div>
</div>
@@ -92,12 +89,12 @@ export default function TransmittalDetailPage() {
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Purpose</p>
<Badge variant="outline">{transmittal.purpose || "OTHER"}</Badge>
<Badge variant="outline">{transmittal.purpose || 'OTHER'}</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Date</p>
<p className="font-medium">
{format(new Date(transmittal.correspondence?.createdAt || transmittal.createdAt), "dd MMM yyyy")}
{format(new Date(transmittal.correspondence?.createdAt || transmittal.createdAt), 'dd MMM yyyy')}
</p>
</div>
<div>
@@ -116,9 +113,7 @@ export default function TransmittalDetailPage() {
{transmittal.remarks && (
<div className="col-span-2">
<p className="text-sm text-muted-foreground">Remarks</p>
<p className="font-medium whitespace-pre-wrap">
{transmittal.remarks}
</p>
<p className="font-medium whitespace-pre-wrap">{transmittal.remarks}</p>
</div>
)}
</CardContent>
@@ -144,10 +139,8 @@ export default function TransmittalDetailPage() {
<TableCell>
<Badge variant="secondary">{item.itemType}</Badge>
</TableCell>
<TableCell className="font-medium">
{item.documentNumber || `ID: ${item.itemId}`}
</TableCell>
<TableCell>{item.description || "-"}</TableCell>
<TableCell className="font-medium">{item.documentNumber || `ID: ${item.itemId}`}</TableCell>
<TableCell>{item.description || '-'}</TableCell>
</TableRow>
))}
{(!transmittal.items || transmittal.items.length === 0) && (
@@ -1,9 +1,9 @@
"use client";
'use client';
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { TransmittalForm } from "@/components/transmittal/transmittal-form";
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { TransmittalForm } from '@/components/transmittal/transmittal-form';
// Force dynamic rendering to prevent build-time prerendering issues
export const dynamic = 'force-dynamic';
@@ -23,9 +23,7 @@ export default function CreateTransmittalPage() {
</Link>
<div>
<h1 className="text-2xl font-bold">Create Transmittal</h1>
<p className="text-muted-foreground">
Prepare a new document transmittal slip
</p>
<p className="text-muted-foreground">Prepare a new document transmittal slip</p>
</div>
</div>
+26 -45
View File
@@ -1,39 +1,28 @@
"use client";
'use client';
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { TransmittalList } from "@/components/transmittal/transmittal-list";
import { transmittalService } from "@/lib/services/transmittal.service";
import { projectService } from "@/lib/services/project.service";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link";
import { TransmittalListResponse } from "@/types/transmittal";
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { TransmittalList } from '@/components/transmittal/transmittal-list';
import { transmittalService } from '@/lib/services/transmittal.service';
import { projectService } from '@/lib/services/project.service';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, RefreshCw } from 'lucide-react';
import Link from 'next/link';
import { TransmittalListResponse } from '@/types/transmittal';
export default function TransmittalPage() {
// ADR-019: Dynamic project selection via UUID
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>("");
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>('');
const { data: projectsData } = useQuery({
queryKey: ["projects-for-transmittals"],
queryKey: ['projects-for-transmittals'],
queryFn: () => projectService.getAll(),
});
const projects = projectsData?.data || projectsData || [];
const {
data,
isLoading,
error,
refetch,
} = useQuery<TransmittalListResponse>({
queryKey: ["transmittals", selectedProjectUuid],
const { data, isLoading, error, refetch } = useQuery<TransmittalListResponse>({
queryKey: ['transmittals', selectedProjectUuid],
queryFn: () => transmittalService.getAll({ projectId: selectedProjectUuid }),
enabled: !!selectedProjectUuid,
});
@@ -43,19 +32,11 @@ export default function TransmittalPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Transmittals</h1>
<p className="text-muted-foreground">
Manage document transmittal slips
</p>
<p className="text-muted-foreground">Manage document transmittal slips</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isLoading}
title="Refresh"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={isLoading} title="Refresh">
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Link href="/transmittals/new">
<Button>
@@ -74,19 +55,19 @@ export default function TransmittalPage() {
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{(Array.isArray(projects) ? projects : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.projectName || p.projectCode}
</SelectItem>
))}
{(Array.isArray(projects) ? projects : []).map(
(p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.projectName || p.projectCode}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
Failed to load transmittals.
</div>
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">Failed to load transmittals.</div>
)}
{isLoading ? (