251211:1622 Frontend: refactor Dashboard (not finish)
This commit is contained in:
@@ -207,15 +207,34 @@ export class CorrespondenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAll(searchDto: SearchCorrespondenceDto = {}) {
|
async findAll(searchDto: SearchCorrespondenceDto = {}) {
|
||||||
const { search, typeId, projectId, statusId } = searchDto;
|
const {
|
||||||
|
search,
|
||||||
|
typeId,
|
||||||
|
projectId,
|
||||||
|
statusId,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
} = searchDto;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const query = this.correspondenceRepo
|
// Change: Query from Revision Repo
|
||||||
.createQueryBuilder('corr')
|
const query = this.revisionRepo
|
||||||
.leftJoinAndSelect('corr.revisions', 'rev')
|
.createQueryBuilder('rev')
|
||||||
|
.leftJoinAndSelect('rev.correspondence', 'corr')
|
||||||
.leftJoinAndSelect('corr.type', 'type')
|
.leftJoinAndSelect('corr.type', 'type')
|
||||||
.leftJoinAndSelect('corr.project', 'project')
|
.leftJoinAndSelect('corr.project', 'project')
|
||||||
.leftJoinAndSelect('corr.originator', 'org')
|
.leftJoinAndSelect('corr.originator', 'org')
|
||||||
.where('rev.isCurrent = :isCurrent', { isCurrent: true });
|
.leftJoinAndSelect('rev.status', 'status');
|
||||||
|
|
||||||
|
// Filter by Revision Status
|
||||||
|
const revStatus = searchDto.revisionStatus || 'CURRENT';
|
||||||
|
|
||||||
|
if (revStatus === 'CURRENT') {
|
||||||
|
query.where('rev.isCurrent = :isCurrent', { isCurrent: true });
|
||||||
|
} else if (revStatus === 'OLD') {
|
||||||
|
query.where('rev.isCurrent = :isCurrent', { isCurrent: false });
|
||||||
|
}
|
||||||
|
// If 'ALL', no filter needed on isCurrent
|
||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
query.andWhere('corr.projectId = :projectId', { projectId });
|
query.andWhere('corr.projectId = :projectId', { projectId });
|
||||||
@@ -236,9 +255,20 @@ export class CorrespondenceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
query.orderBy('corr.createdAt', 'DESC');
|
// Default Sort: Latest Created
|
||||||
|
query.orderBy('rev.createdAt', 'DESC').skip(skip).take(limit);
|
||||||
|
|
||||||
return query.getMany();
|
const [items, total] = await query.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: items,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export class SearchCorrespondenceDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
statusId?: number;
|
statusId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Revision Filter: CURRENT (default), ALL, OLD',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
revisionStatus?: 'CURRENT' | 'ALL' | 'OLD';
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Page number (default 1)', default: 1 })
|
@ApiPropertyOptional({ description: 'Page number (default 1)', default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -80,10 +80,31 @@ export class DashboardService {
|
|||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// นับเอกสารที่อนุมัติแล้ว (APPROVED)
|
||||||
|
// NOTE: อาจจะต้องปรับ logic ตาม Business ว่า "อนุมัติ" หมายถึงอะไร
|
||||||
|
// เบื้องต้นนับจาก CorrespondenceStatus ที่เป็น 'APPROVED' หรือ 'CODE 1'
|
||||||
|
// หรือนับจาก Workflow ที่ Completed และ Action เป็น APPROVE
|
||||||
|
// เพื่อความง่ายในเบื้องต้น นับจาก CorrespondenceRevision ที่มี status 'APPROVED' (ถ้ามี)
|
||||||
|
// หรือนับจาก RFA ที่มี Approve Code
|
||||||
|
|
||||||
|
// สำหรับ LCBP3 นับ RFA ที่ approveCodeId ไม่ใช่ null (หรือ check status code = APR/FAP)
|
||||||
|
// และ Correspondence ทั่วไปที่มีสถานะ Completed
|
||||||
|
// เพื่อความรวดเร็ว ใช้วิธีนับ Revision ที่ isCurrent = 1 และ statusCode = 'APR' (Approved)
|
||||||
|
|
||||||
|
// Check status code 'APR' exists
|
||||||
|
const aprStatusCount = await this.dataSource.query(`
|
||||||
|
SELECT COUNT(r.id) as count
|
||||||
|
FROM correspondence_revisions r
|
||||||
|
JOIN correspondence_status s ON r.correspondence_status_id = s.id
|
||||||
|
WHERE r.is_current = 1 AND s.status_code IN ('APR', 'CMP')
|
||||||
|
`);
|
||||||
|
const approved = parseInt(aprStatusCount[0]?.count || '0', 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalDocuments,
|
totalDocuments,
|
||||||
documentsThisMonth,
|
documentsThisMonth,
|
||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
|
approved,
|
||||||
totalRfas,
|
totalRfas,
|
||||||
totalCirculations,
|
totalCirculations,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export class DashboardStatsDto {
|
|||||||
@ApiProperty({ description: 'จำนวนงานที่รออนุมัติ', example: 12 })
|
@ApiProperty({ description: 'จำนวนงานที่รออนุมัติ', example: 12 })
|
||||||
pendingApprovals!: number;
|
pendingApprovals!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'จำนวนเอกสารที่อนุมัติแล้ว', example: 100 })
|
||||||
|
approved!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'จำนวน RFA ทั้งหมด', example: 45 })
|
@ApiProperty({ description: 'จำนวน RFA ทั้งหมด', example: 45 })
|
||||||
totalRfas!: number;
|
totalRfas!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export class RfaService {
|
|||||||
|
|
||||||
const rfaItems = shopDrawings.map((sd) =>
|
const rfaItems = shopDrawings.map((sd) =>
|
||||||
queryRunner.manager.create(RfaItem, {
|
queryRunner.manager.create(RfaItem, {
|
||||||
rfaRevisionId: savedCorr.id, // Use Correspondence ID as per schema
|
rfaRevisionId: savedRevision.id, // Correctly link to RfaRevision
|
||||||
shopDrawingRevisionId: sd.id,
|
shopDrawingRevisionId: sd.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -244,8 +244,22 @@ export class RfaService {
|
|||||||
.createQueryBuilder('rfa')
|
.createQueryBuilder('rfa')
|
||||||
.leftJoinAndSelect('rfa.revisions', 'rev')
|
.leftJoinAndSelect('rfa.revisions', 'rev')
|
||||||
.leftJoinAndSelect('rev.correspondence', 'corr')
|
.leftJoinAndSelect('rev.correspondence', 'corr')
|
||||||
|
.leftJoinAndSelect('corr.project', 'project')
|
||||||
|
.leftJoinAndSelect('rfa.discipline', 'discipline')
|
||||||
.leftJoinAndSelect('rev.statusCode', 'status')
|
.leftJoinAndSelect('rev.statusCode', 'status')
|
||||||
.where('rev.isCurrent = :isCurrent', { isCurrent: true });
|
.leftJoinAndSelect('rev.items', 'items')
|
||||||
|
.leftJoinAndSelect('items.shopDrawingRevision', 'sdRev')
|
||||||
|
.leftJoinAndSelect('sdRev.attachments', 'attachments');
|
||||||
|
|
||||||
|
// Filter by Revision Status (from query param 'revisionStatus')
|
||||||
|
const revStatus = query.revisionStatus || 'CURRENT';
|
||||||
|
|
||||||
|
if (revStatus === 'CURRENT') {
|
||||||
|
queryBuilder.where('rev.isCurrent = :isCurrent', { isCurrent: true });
|
||||||
|
} else if (revStatus === 'OLD') {
|
||||||
|
queryBuilder.where('rev.isCurrent = :isCurrent', { isCurrent: false });
|
||||||
|
}
|
||||||
|
// If 'ALL', no filter
|
||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
queryBuilder.andWhere('corr.projectId = :projectId', { projectId });
|
queryBuilder.andWhere('corr.projectId = :projectId', { projectId });
|
||||||
@@ -268,6 +282,10 @@ export class RfaService {
|
|||||||
.take(limit)
|
.take(limit)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[DEBUG] RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: items,
|
data: items,
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -34,11 +34,40 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
// import { useProjects } from "@/lib/services/project.service"; // Removed invalid import
|
import { contractService } from "@/lib/services/contract.service";
|
||||||
// I need to import useProjects hook from the page where it was defined or create it.
|
import {
|
||||||
// Checking projects/page.tsx, it uses useProjects from somewhere?
|
AlertDialog,
|
||||||
// Ah, usually I define hooks in a separate file or inline if simple.
|
AlertDialogAction,
|
||||||
// Let's rely on standard react-query params here.
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SearchContractDto, CreateContractDto, UpdateContractDto } from "@/types/dto/contract/contract.dto";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Contract {
|
||||||
|
id: number;
|
||||||
|
contractCode: string;
|
||||||
|
contractName: string;
|
||||||
|
projectId: number;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
project?: {
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const contractSchema = z.object({
|
const contractSchema = z.object({
|
||||||
contractCode: z.string().min(1, "Contract Code is required"),
|
contractCode: z.string().min(1, "Contract Code is required"),
|
||||||
@@ -51,10 +80,7 @@ const contractSchema = z.object({
|
|||||||
|
|
||||||
type ContractFormData = z.infer<typeof contractSchema>;
|
type ContractFormData = z.infer<typeof contractSchema>;
|
||||||
|
|
||||||
import { contractService } from "@/lib/services/contract.service";
|
const useContracts = (params?: SearchContractDto) => {
|
||||||
|
|
||||||
// Inline hooks for simplicity, or could move to hooks/use-master-data
|
|
||||||
const useContracts = (params?: any) => {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['contracts', params],
|
queryKey: ['contracts', params],
|
||||||
queryFn: () => contractService.getAll(params),
|
queryFn: () => contractService.getAll(params),
|
||||||
@@ -76,23 +102,23 @@ export default function ContractsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const createContract = useMutation({
|
const createContract = useMutation({
|
||||||
mutationFn: (data: any) => apiClient.post("/contracts", data).then(res => res.data),
|
mutationFn: (data: CreateContractDto) => apiClient.post("/contracts", data).then(res => res.data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Contract created successfully");
|
toast.success("Contract created successfully");
|
||||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.message || "Failed to create contract")
|
onError: (err: AxiosError<{ message: string }>) => toast.error(err.response?.data?.message || "Failed to create contract")
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateContract = useMutation({
|
const updateContract = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number, data: any }) => apiClient.patch(`/contracts/${id}`, data).then(res => res.data),
|
mutationFn: ({ id, data }: { id: number, data: UpdateContractDto }) => apiClient.patch(`/contracts/${id}`, data).then(res => res.data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Contract updated successfully");
|
toast.success("Contract updated successfully");
|
||||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.message || "Failed to update contract")
|
onError: (err: AxiosError<{ message: string }>) => toast.error(err.response?.data?.message || "Failed to update contract")
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteContract = useMutation({
|
const deleteContract = useMutation({
|
||||||
@@ -101,12 +127,32 @@ export default function ContractsPage() {
|
|||||||
toast.success("Contract deleted successfully");
|
toast.success("Contract deleted successfully");
|
||||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.message || "Failed to delete contract")
|
onError: (err: AxiosError<{ message: string }>) => toast.error(err.response?.data?.message || "Failed to delete contract")
|
||||||
});
|
});
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Stats for Delete Dialog
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
|
||||||
|
|
||||||
|
const handleDeleteClick = (contract: Contract) => {
|
||||||
|
setContractToDelete(contract);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (contractToDelete) {
|
||||||
|
deleteContract.mutate(contractToDelete.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setContractToDelete(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -124,7 +170,7 @@ export default function ContractsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<Contract>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "contractCode",
|
accessorKey: "contractCode",
|
||||||
header: "Code",
|
header: "Code",
|
||||||
@@ -154,12 +200,8 @@ export default function ContractsPage() {
|
|||||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
onClick={() => {
|
onClick={() => handleDeleteClick(row.original)}
|
||||||
if (confirm(`Delete contract ${row.original.contractCode}?`)) {
|
|
||||||
deleteContract.mutate(row.original.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -169,7 +211,7 @@ export default function ContractsPage() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleEdit = (contract: any) => {
|
const handleEdit = (contract: Contract) => {
|
||||||
setEditingId(contract.id);
|
setEditingId(contract.id);
|
||||||
reset({
|
reset({
|
||||||
contractCode: contract.contractCode,
|
contractCode: contract.contractCode,
|
||||||
@@ -233,7 +275,13 @@ export default function ContractsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-10">Loading contracts...</div>
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable columns={columns} data={contracts || []} />
|
<DataTable columns={columns} data={contracts || []} />
|
||||||
)}
|
)}
|
||||||
@@ -255,7 +303,7 @@ export default function ContractsPage() {
|
|||||||
<SelectValue placeholder="Select Project" />
|
<SelectValue placeholder="Select Project" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{projects?.map((p: any) => (
|
{(projects as Project[])?.map((p) => (
|
||||||
<SelectItem key={p.id} value={p.id.toString()}>
|
<SelectItem key={p.id} value={p.id.toString()}>
|
||||||
{p.projectCode} - {p.projectName}
|
{p.projectCode} - {p.projectName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -321,6 +369,28 @@ export default function ContractsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the contract
|
||||||
|
<span className="font-semibold text-foreground"> {contractToDelete?.contractCode} </span>
|
||||||
|
and remove it from the system.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{deleteContract.isPending ? "Deleting..." : "Delete Contract"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Organization } from "@/types/organization";
|
import { Organization } from "@/types/organization";
|
||||||
import { OrganizationDialog } from "@/components/admin/organization-dialog";
|
import { OrganizationDialog } from "@/components/admin/organization-dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
// Organization role types for display
|
// Organization role types for display
|
||||||
const ORGANIZATION_ROLES = [
|
const ORGANIZATION_ROLES = [
|
||||||
@@ -41,6 +52,26 @@ export default function OrganizationsPage() {
|
|||||||
const [selectedOrganization, setSelectedOrganization] =
|
const [selectedOrganization, setSelectedOrganization] =
|
||||||
useState<Organization | null>(null);
|
useState<Organization | null>(null);
|
||||||
|
|
||||||
|
// Stats for Delete Dialog
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [orgToDelete, setOrgToDelete] = useState<Organization | null>(null);
|
||||||
|
|
||||||
|
const handleDeleteClick = (org: Organization) => {
|
||||||
|
setOrgToDelete(org);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (orgToDelete) {
|
||||||
|
deleteOrg.mutate(orgToDelete.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setOrgToDelete(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<Organization>[] = [
|
const columns: ColumnDef<Organization>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "organizationCode",
|
accessorKey: "organizationCode",
|
||||||
@@ -101,12 +132,8 @@ export default function OrganizationsPage() {
|
|||||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
onClick={() => {
|
onClick={() => handleDeleteClick(org)}
|
||||||
if (confirm(`Delete organization ${org.organizationCode}?`)) {
|
|
||||||
deleteOrg.mutate(org.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -149,7 +176,13 @@ export default function OrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-10">Loading organizations...</div>
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable columns={columns} data={organizations || []} />
|
<DataTable columns={columns} data={organizations || []} />
|
||||||
)}
|
)}
|
||||||
@@ -159,6 +192,28 @@ export default function OrganizationsPage() {
|
|||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
organization={selectedOrganization}
|
organization={selectedOrganization}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the organization
|
||||||
|
<span className="font-semibold text-foreground"> {orgToDelete?.organizationName} </span>
|
||||||
|
and remove it from the system.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{deleteOrg.isPending ? "Deleting..." : "Delete Organization"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,147 @@
|
|||||||
import { redirect } from 'next/navigation';
|
"use client";
|
||||||
|
|
||||||
|
import { useOrganizations } from "@/hooks/use-master-data";
|
||||||
|
import { useUsers } from "@/hooks/use-users";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
redirect('/admin/workflows');
|
const { data: organizations, isLoading: orgsLoading } = useOrganizations();
|
||||||
|
const { data: users, isLoading: usersLoading } = useUsers();
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: "Total Users",
|
||||||
|
value: users?.length || 0,
|
||||||
|
icon: Users,
|
||||||
|
loading: usersLoading,
|
||||||
|
href: "/admin/users",
|
||||||
|
color: "text-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Organizations",
|
||||||
|
value: organizations?.length || 0,
|
||||||
|
icon: Building2,
|
||||||
|
loading: orgsLoading,
|
||||||
|
href: "/admin/organizations",
|
||||||
|
color: "text-green-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "System Logs",
|
||||||
|
value: "View",
|
||||||
|
icon: Activity,
|
||||||
|
loading: false,
|
||||||
|
href: "/admin/system-logs",
|
||||||
|
color: "text-orange-600",
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
{
|
||||||
|
title: "User Management",
|
||||||
|
description: "Manage system users, roles, and permissions",
|
||||||
|
href: "/admin/users",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Organizations",
|
||||||
|
description: "Manage project organizations and companies",
|
||||||
|
href: "/admin/organizations",
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Workflow Config",
|
||||||
|
description: "Configure document approval workflows",
|
||||||
|
href: "/admin/workflows",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Security & RBAC",
|
||||||
|
description: "Configure roles, permissions, and security settings",
|
||||||
|
href: "/admin/security/roles",
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Numbering System",
|
||||||
|
description: "Setup document numbering templates",
|
||||||
|
href: "/admin/numbering",
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
System overview and quick access to administrative functions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<Card key={index} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{stat.title}
|
||||||
|
</CardTitle>
|
||||||
|
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stat.loading ? (
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold">{stat.value}</div>
|
||||||
|
)}
|
||||||
|
{stat.href && (
|
||||||
|
<Link
|
||||||
|
href={stat.href}
|
||||||
|
className="text-xs text-muted-foreground hover:underline mt-1 inline-block"
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Quick Access</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{quickLinks.map((link, index) => (
|
||||||
|
<Link key={index} href={link.href}>
|
||||||
|
<Card className="h-full hover:bg-muted/50 transition-colors cursor-pointer border-l-4 border-l-transparent hover:border-l-primary">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center text-lg">
|
||||||
|
<link.icon className="mr-2 h-5 w-5 text-primary" />
|
||||||
|
{link.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{link.description}
|
||||||
|
</p>
|
||||||
|
<Button variant="ghost" className="mt-4 p-0 h-auto font-normal text-primary hover:no-underline group">
|
||||||
|
Go to module <ArrowRight className="ml-1 h-3 w-3 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -60,6 +71,26 @@ export default function ProjectsPage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Stats for Delete Dialog
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
const handleDeleteClick = (project: Project) => {
|
||||||
|
setProjectToDelete(project);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (projectToDelete) {
|
||||||
|
deleteProject.mutate(projectToDelete.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setProjectToDelete(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -113,12 +144,8 @@ export default function ProjectsPage() {
|
|||||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
onClick={() => {
|
onClick={() => handleDeleteClick(row.original)}
|
||||||
if (confirm(`Delete project ${row.original.projectCode}?`)) {
|
|
||||||
deleteProject.mutate(row.original.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -190,7 +217,13 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-10">Loading projects...</div>
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable columns={columns} data={projects || []} />
|
<DataTable columns={columns} data={projects || []} />
|
||||||
)}
|
)}
|
||||||
@@ -253,6 +286,28 @@ export default function ProjectsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the project
|
||||||
|
<span className="font-semibold text-foreground"> {projectToDelete?.projectCode} </span>
|
||||||
|
and remove it from the system.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{deleteProject.isPending ? "Deleting..." : "Delete Project"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,21 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||||||
export default function DrawingCategoriesPage() {
|
export default function DrawingCategoriesPage() {
|
||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "type_code",
|
accessorKey: "subTypeCode",
|
||||||
header: "Code",
|
header: "Code",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono font-bold">{row.getValue("type_code")}</span>
|
<span className="font-mono font-bold">{row.getValue("subTypeCode")}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "type_name",
|
accessorKey: "subTypeName",
|
||||||
header: "Name",
|
header: "Name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "classification",
|
accessorKey: "subTypeNumber",
|
||||||
header: "Classification",
|
header: "Running Code",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="capitalize">{row.getValue("classification") || "General"}</span>
|
<span className="font-mono">{row.getValue("subTypeNumber") || "-"}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -34,14 +34,14 @@ export default function DrawingCategoriesPage() {
|
|||||||
description="Manage drawing sub-types and categories"
|
description="Manage drawing sub-types and categories"
|
||||||
queryKey={["drawing-categories"]}
|
queryKey={["drawing-categories"]}
|
||||||
fetchFn={() => masterDataService.getSubTypes(1)} // Default contract ID 1
|
fetchFn={() => masterDataService.getSubTypes(1)} // Default contract ID 1
|
||||||
createFn={(data) => masterDataService.createSubType({ ...data, contractId: 1 })}
|
createFn={(data) => masterDataService.createSubType({ ...data, contractId: 1, correspondenceTypeId: 3 })} // Assuming 3 is Drawings, hardcoded for now to prevent error
|
||||||
updateFn={(id, data) => Promise.reject("Not implemented yet")}
|
updateFn={() => Promise.reject("Not implemented yet")}
|
||||||
deleteFn={(id) => Promise.reject("Not implemented yet")} // Delete might be restricted
|
deleteFn={() => Promise.reject("Not implemented yet")} // Delete might be restricted
|
||||||
columns={columns}
|
columns={columns}
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "type_code", label: "Code", type: "text", required: true },
|
{ name: "subTypeCode", label: "Code", type: "text", required: true },
|
||||||
{ name: "type_name", label: "Name", type: "text", required: true },
|
{ name: "subTypeName", label: "Name", type: "text", required: true },
|
||||||
{ name: "classification", label: "Classification", type: "text" },
|
{ name: "subTypeNumber", label: "Running Code", type: "text" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const refMenu = [
|
|||||||
href: "/admin/reference/tags",
|
href: "/admin/reference/tags",
|
||||||
icon: Tag,
|
icon: Tag,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Drawing Categories",
|
||||||
|
description: "Manage drawing sub-types and classifications",
|
||||||
|
href: "/admin/reference/drawing-categories",
|
||||||
|
icon: Layers,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ReferenceDataPage() {
|
export default function ReferenceDataPage() {
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
import { Organization } from "@/types/organization";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -40,6 +53,27 @@ export default function UsersPage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
// Stats for Delete Dialog
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const handleDeleteClick = (user: User) => {
|
||||||
|
setUserToDelete(user);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (userToDelete) {
|
||||||
|
deleteMutation.mutate(userToDelete.userId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setUserToDelete(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const columns: ColumnDef<User>[] = [
|
const columns: ColumnDef<User>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "username",
|
accessorKey: "username",
|
||||||
@@ -59,12 +93,8 @@ export default function UsersPage() {
|
|||||||
id: "organization",
|
id: "organization",
|
||||||
header: "Organization",
|
header: "Organization",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
// Need to find org in list if not populated or if only ID exists
|
|
||||||
// Assuming backend populates organization object or we map it from ID
|
|
||||||
// Currently User type has organization?
|
|
||||||
// Let's rely on finding it from the master data if missing
|
|
||||||
const orgId = row.original.primaryOrganizationId;
|
const orgId = row.original.primaryOrganizationId;
|
||||||
const org = organizations.find((o: any) => o.id === orgId);
|
const org = (organizations as Organization[]).find((o) => o.id === orgId);
|
||||||
return org ? org.organizationCode : "-";
|
return org ? org.organizationCode : "-";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -73,7 +103,6 @@ export default function UsersPage() {
|
|||||||
header: "Roles",
|
header: "Roles",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const roles = row.original.roles || [];
|
const roles = row.original.roles || [];
|
||||||
// If roles is empty, it might be lazy loaded or just assignments
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{roles.map((r) => (
|
{roles.map((r) => (
|
||||||
@@ -112,10 +141,8 @@ export default function UsersPage() {
|
|||||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
onClick={() => {
|
onClick={() => handleDeleteClick(user)}
|
||||||
if (confirm("Are you sure?")) deleteMutation.mutate(user.userId);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -158,7 +185,7 @@ export default function UsersPage() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Organizations</SelectItem>
|
<SelectItem value="all">All Organizations</SelectItem>
|
||||||
{Array.isArray(organizations) && organizations.map((org: any) => (
|
{Array.isArray(organizations) && (organizations as Organization[]).map((org) => (
|
||||||
<SelectItem key={org.id} value={org.id.toString()}>
|
<SelectItem key={org.id} value={org.id.toString()}>
|
||||||
{org.organizationCode} - {org.organizationName}
|
{org.organizationCode} - {org.organizationName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -169,7 +196,13 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-10">Loading users...</div>
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable columns={columns} data={users || []} />
|
<DataTable columns={columns} data={users || []} />
|
||||||
)}
|
)}
|
||||||
@@ -179,6 +212,28 @@ export default function UsersPage() {
|
|||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the user
|
||||||
|
<span className="font-semibold text-foreground"> {userToDelete?.username} </span>
|
||||||
|
and remove them from the system.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? "Deleting..." : "Delete User"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
44
frontend/app/(dashboard)/correspondences/[id]/edit/page.tsx
Normal file
44
frontend/app/(dashboard)/correspondences/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CorrespondenceForm } from "@/components/correspondences/form";
|
||||||
|
import { useCorrespondence } from "@/hooks/use-correspondence";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
export default function EditCorrespondencePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = Number(params?.id);
|
||||||
|
|
||||||
|
const { data: correspondence, isLoading, isError } = useCorrespondence(id);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-6">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">Edit Correspondence</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{correspondence.correspondenceNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-6 shadow-sm">
|
||||||
|
<CorrespondenceForm initialData={correspondence} id={id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,45 @@
|
|||||||
import { correspondenceApi } from "@/lib/api/correspondences";
|
"use client";
|
||||||
|
|
||||||
import { CorrespondenceDetail } from "@/components/correspondences/detail";
|
import { CorrespondenceDetail } from "@/components/correspondences/detail";
|
||||||
import { notFound } from "next/navigation";
|
import { useCorrespondence } from "@/hooks/use-correspondence";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { notFound, useParams } from "next/navigation";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export default function CorrespondenceDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = Number(params?.id); // useParams returns string | string[]
|
||||||
|
|
||||||
export default async function CorrespondenceDetailPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: { id: string };
|
|
||||||
}) {
|
|
||||||
const id = parseInt(params.id);
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
notFound();
|
// We can't use notFound() directly in client component render without breaking sometimes,
|
||||||
|
// but typically it works. Better to handle gracefully or redirect.
|
||||||
|
// For now, let's keep it or return 404 UI.
|
||||||
|
// Actually notFound() is for server components mostly.
|
||||||
|
// Let's just return our error UI if ID is invalid.
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence ID</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const correspondence = await correspondenceApi.getById(id);
|
const { data: correspondence, isLoading, isError } = useCorrespondence(id);
|
||||||
|
|
||||||
if (!correspondence) {
|
if (isLoading) {
|
||||||
notFound();
|
return (
|
||||||
|
<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) {
|
||||||
|
// Optionally handle 404 vs other errors differently, but for now simple handling
|
||||||
|
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 ID.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CorrespondenceDetail data={correspondence} />;
|
return <CorrespondenceDetail data={correspondence} />;
|
||||||
|
|||||||
@@ -16,11 +16,30 @@ function RFAsContent() {
|
|||||||
const search = searchParams.get('search') || undefined;
|
const search = searchParams.get('search') || undefined;
|
||||||
const projectId = searchParams.get('projectId') ? parseInt(searchParams.get('projectId')!) : undefined;
|
const projectId = searchParams.get('projectId') ? parseInt(searchParams.get('projectId')!) : undefined;
|
||||||
|
|
||||||
const { data, isLoading, isError } = useRFAs({ page, statusId, search, projectId });
|
const revisionStatus = (searchParams.get('revisionStatus') as 'CURRENT' | 'ALL' | 'OLD') || 'CURRENT';
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useRFAs({ page, statusId, search, projectId, revisionStatus });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* RFAFilters component could be added here if needed */}
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
@@ -32,12 +51,12 @@ function RFAsContent() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RFAList data={data} />
|
<RFAList data={data?.data || []} />
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={data?.page || 1}
|
currentPage={data?.meta?.page || 1}
|
||||||
totalPages={data?.lastPage || data?.totalPages || 1}
|
totalPages={data?.meta?.totalPages || 1}
|
||||||
total={data?.total || 0}
|
total={data?.meta?.total || 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ import {
|
|||||||
import { Plus, Pencil, Trash2, RefreshCw } from "lucide-react";
|
import { Plus, Pencil, Trash2, RefreshCw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
interface FieldConfig {
|
interface FieldConfig {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -66,6 +77,10 @@ export function GenericCrudTable({
|
|||||||
const [editingItem, setEditingItem] = useState<any>(null);
|
const [editingItem, setEditingItem] = useState<any>(null);
|
||||||
const [formData, setFormData] = useState<any>({});
|
const [formData, setFormData] = useState<any>({});
|
||||||
|
|
||||||
|
// Delete Dialog State
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: fetchFn,
|
queryFn: fetchFn,
|
||||||
@@ -96,6 +111,8 @@ export function GenericCrudTable({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`${entityName} deleted successfully`);
|
toast.success(`${entityName} deleted successfully`);
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setItemToDelete(null);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(`Failed to delete ${entityName}`),
|
onError: () => toast.error(`Failed to delete ${entityName}`),
|
||||||
});
|
});
|
||||||
@@ -112,9 +129,14 @@ export function GenericCrudTable({
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDeleteClick = (id: number) => {
|
||||||
if (confirm(`Are you sure you want to delete this ${entityName}?`)) {
|
setItemToDelete(id);
|
||||||
deleteMutation.mutate(id);
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (itemToDelete) {
|
||||||
|
deleteMutation.mutate(itemToDelete);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,7 +178,7 @@ export function GenericCrudTable({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => handleDelete(row.original.id)}
|
onClick={() => handleDeleteClick(row.original.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -191,7 +213,17 @@ export function GenericCrudTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable columns={tableColumns} data={data || []} />
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable columns={tableColumns} data={data || []} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -270,6 +302,26 @@ export function GenericCrudTable({
|
|||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the {entityName.toLowerCase()} and remove it from the system.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Pagination } from "@/components/common/pagination";
|
|||||||
import { useCorrespondences } from "@/hooks/use-correspondence";
|
import { useCorrespondences } from "@/hooks/use-correspondence";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export function CorrespondencesContent() {
|
export function CorrespondencesContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -12,10 +14,13 @@ export function CorrespondencesContent() {
|
|||||||
const status = searchParams.get("status") || undefined;
|
const status = searchParams.get("status") || undefined;
|
||||||
const search = searchParams.get("search") || undefined;
|
const search = searchParams.get("search") || undefined;
|
||||||
|
|
||||||
|
const revisionStatus = (searchParams.get('revisionStatus') as 'CURRENT' | 'ALL' | 'OLD') || 'CURRENT';
|
||||||
|
|
||||||
const { data, isLoading, isError } = useCorrespondences({
|
const { data, isLoading, isError } = useCorrespondences({
|
||||||
page,
|
page,
|
||||||
status,
|
status,
|
||||||
search,
|
search,
|
||||||
|
revisionStatus,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -36,12 +41,27 @@ export function CorrespondencesContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CorrespondenceList data={data} />
|
<div className="mb-4 flex gap-2">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CorrespondenceList data={data?.data || []} />
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={data?.page || 1}
|
currentPage={data?.meta?.page || 1}
|
||||||
totalPages={data?.totalPages || 1}
|
totalPages={data?.meta?.totalPages || 1}
|
||||||
total={data?.total || 0}
|
total={data?.meta?.total || 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Correspondence, Attachment } from "@/types/correspondence";
|
import { Correspondence } from "@/types/correspondence";
|
||||||
import { StatusBadge } from "@/components/common/status-badge";
|
import { StatusBadge } from "@/components/common/status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle } from "lucide-react";
|
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle, Edit } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSubmitCorrespondence, useProcessWorkflow } from "@/hooks/use-correspondence";
|
import { useSubmitCorrespondence, useProcessWorkflow } from "@/hooks/use-correspondence";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -22,11 +22,24 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
|
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
|
||||||
const [comments, setComments] = useState("");
|
const [comments, setComments] = useState("");
|
||||||
|
|
||||||
|
if (!data) return <div>No data found</div>;
|
||||||
|
|
||||||
|
console.log("Correspondence Detail Data:", data);
|
||||||
|
|
||||||
|
// Derive Current Revision Data
|
||||||
|
const currentRevision = data.revisions?.find(r => r.isCurrent) || data.revisions?.[0];
|
||||||
|
const subject = currentRevision?.title || "-";
|
||||||
|
const description = currentRevision?.description || "-";
|
||||||
|
const status = currentRevision?.status?.statusCode || "UNKNOWN"; // e.g. DRAFT
|
||||||
|
const attachments = currentRevision?.attachments || [];
|
||||||
|
|
||||||
|
// Note: Importance might be in details
|
||||||
|
const importance = currentRevision?.details?.importance || "NORMAL";
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (confirm("Are you sure you want to submit this correspondence?")) {
|
if (confirm("Are you sure you want to submit this correspondence?")) {
|
||||||
// TODO: Implement Template Selection. Hardcoded to 1 for now.
|
|
||||||
submitMutation.mutate({
|
submitMutation.mutate({
|
||||||
id: data.correspondenceId,
|
id: data.id,
|
||||||
data: {}
|
data: {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -37,7 +50,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
|
|
||||||
const action = actionState === "approve" ? "APPROVE" : "REJECT";
|
const action = actionState === "approve" ? "APPROVE" : "REJECT";
|
||||||
processMutation.mutate({
|
processMutation.mutate({
|
||||||
id: data.correspondenceId,
|
id: data.id,
|
||||||
data: {
|
data: {
|
||||||
action,
|
action,
|
||||||
comments
|
comments
|
||||||
@@ -61,20 +74,30 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{data.documentNumber}</h1>
|
<h1 className="text-2xl font-bold">{data.correspondenceNumber}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Created on {format(new Date(data.createdAt), "dd MMM yyyy HH:mm")}
|
Created on {data.createdAt ? format(new Date(data.createdAt), "dd MMM yyyy HH:mm") : '-'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{data.status === "DRAFT" && (
|
{/* EDIT BUTTON LOGIC: Show if DRAFT */}
|
||||||
|
{status === "DRAFT" && (
|
||||||
|
<Link href={`/correspondences/${data.id}/edit`}>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "DRAFT" && (
|
||||||
<Button onClick={handleSubmit} disabled={submitMutation.isPending}>
|
<Button onClick={handleSubmit} disabled={submitMutation.isPending}>
|
||||||
{submitMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
{submitMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||||
Submit for Review
|
Submit for Review
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{data.status === "IN_REVIEW" && (
|
{status === "IN_REVIEW" && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -134,15 +157,15 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<CardTitle className="text-xl">{data.subject}</CardTitle>
|
<CardTitle className="text-xl">{subject}</CardTitle>
|
||||||
<StatusBadge status={data.status} />
|
<StatusBadge status={status} />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-2">Description</h3>
|
<h3 className="font-semibold mb-2">Description</h3>
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">
|
<p className="text-gray-700 whitespace-pre-wrap">
|
||||||
{data.description || "No description provided."}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,9 +173,9 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-3">Attachments</h3>
|
<h3 className="font-semibold mb-3">Attachments</h3>
|
||||||
{data.attachments && data.attachments.length > 0 ? (
|
{attachments && attachments.length > 0 ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{data.attachments.map((file, index) => (
|
{attachments.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={file.id || index}
|
key={file.id || index}
|
||||||
className="flex items-center justify-between p-3 border rounded-lg bg-muted/20"
|
className="flex items-center justify-between p-3 border rounded-lg bg-muted/20"
|
||||||
@@ -170,7 +193,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground italic">No attachments.</p>
|
<p className="text-sm text-muted-foreground italic">No attachments found.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -188,10 +211,10 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
<p className="text-sm font-medium text-muted-foreground">Importance</p>
|
<p className="text-sm font-medium text-muted-foreground">Importance</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
${data.importance === 'URGENT' ? 'bg-red-100 text-red-800' :
|
${importance === 'URGENT' ? 'bg-red-100 text-red-800' :
|
||||||
data.importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
|
importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
|
||||||
'bg-blue-100 text-blue-800'}`}>
|
'bg-blue-100 text-blue-800'}`}>
|
||||||
{data.importance}
|
{importance}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,15 +222,15 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
|
|||||||
<hr className="my-4 border-t" />
|
<hr className="my-4 border-t" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">From Organization</p>
|
<p className="text-sm font-medium text-muted-foreground">Originator</p>
|
||||||
<p className="font-medium mt-1">{data.fromOrganization?.orgName}</p>
|
<p className="font-medium mt-1">{data.originator?.orgName || '-'}</p>
|
||||||
<p className="text-xs text-muted-foreground">{data.fromOrganization?.orgCode}</p>
|
<p className="text-xs text-muted-foreground">{data.originator?.orgCode || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">To Organization</p>
|
<p className="text-sm font-medium text-muted-foreground">Project</p>
|
||||||
<p className="font-medium mt-1">{data.toOrganization?.orgName}</p>
|
<p className="font-medium mt-1">{data.project?.projectName || '-'}</p>
|
||||||
<p className="text-xs text-muted-foreground">{data.toOrganization?.orgCode}</p>
|
<p className="text-xs text-muted-foreground">{data.project?.projectCode || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { FileUpload } from "@/components/common/file-upload";
|
import { FileUpload } from "@/components/common/file-upload";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useCreateCorrespondence } from "@/hooks/use-correspondence";
|
import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence";
|
||||||
import { Organization } from "@/types/organization";
|
import { Organization } from "@/types/organization";
|
||||||
import { useOrganizations } from "@/hooks/use-master-data";
|
import { useOrganizations } from "@/hooks/use-master-data";
|
||||||
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
|
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
|
||||||
@@ -34,50 +34,66 @@ const correspondenceSchema = z.object({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof correspondenceSchema>;
|
type FormData = z.infer<typeof correspondenceSchema>;
|
||||||
|
|
||||||
export function CorrespondenceForm() {
|
export function CorrespondenceForm({ initialData, id }: { initialData?: any, id?: number }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const createMutation = useCreateCorrespondence();
|
const createMutation = useCreateCorrespondence();
|
||||||
|
const updateMutation = useUpdateCorrespondence(); // Add this hook
|
||||||
const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations();
|
const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations();
|
||||||
|
|
||||||
|
// Extract initial values if editing
|
||||||
|
const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0];
|
||||||
|
const defaultValues: Partial<FormData> = {
|
||||||
|
subject: currentRev?.title || "",
|
||||||
|
description: currentRev?.description || "",
|
||||||
|
documentTypeId: initialData?.correspondenceTypeId || 1,
|
||||||
|
fromOrganizationId: initialData?.originatorId || undefined,
|
||||||
|
toOrganizationId: currentRev?.details?.to_organization_id || undefined,
|
||||||
|
importance: currentRev?.details?.importance || "NORMAL",
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
|
watch, // Watch values to control Select value props if needed, or rely on RHF
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(correspondenceSchema),
|
resolver: zodResolver(correspondenceSchema),
|
||||||
defaultValues: {
|
defaultValues: defaultValues as any,
|
||||||
importance: "NORMAL",
|
|
||||||
documentTypeId: 1,
|
|
||||||
} as any, // Cast to any to handle partial defaults for required fields
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch for controlled inputs to set value in Select components if needed for better UX
|
||||||
|
const fromOrgId = watch("fromOrganizationId");
|
||||||
|
const toOrgId = watch("toOrganizationId");
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => {
|
const onSubmit = (data: FormData) => {
|
||||||
// Map FormData to CreateCorrespondenceDto
|
|
||||||
// Note: projectId is hardcoded to 1 for now as per requirements/context
|
|
||||||
const payload: CreateCorrespondenceDto = {
|
const payload: CreateCorrespondenceDto = {
|
||||||
projectId: 1,
|
projectId: 1,
|
||||||
typeId: data.documentTypeId,
|
typeId: data.documentTypeId,
|
||||||
title: data.subject,
|
title: data.subject,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
originatorId: data.fromOrganizationId, // Mapping From -> Originator (Impersonation)
|
originatorId: data.fromOrganizationId,
|
||||||
details: {
|
details: {
|
||||||
to_organization_id: data.toOrganizationId,
|
to_organization_id: data.toOrganizationId,
|
||||||
importance: data.importance
|
importance: data.importance
|
||||||
},
|
},
|
||||||
// create-correspondence DTO does not have 'attachments' field at root usually, often handled separate or via multipart
|
|
||||||
// If useCreateCorrespondence handles multipart, we might need to pass FormData object or specific structure
|
|
||||||
// For now, aligning with DTO interface.
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// If the hook expects the DTO directly:
|
if (id && initialData) {
|
||||||
createMutation.mutate(payload, {
|
// UPDATE Mode
|
||||||
onSuccess: () => {
|
updateMutation.mutate({ id, data: payload }, {
|
||||||
router.push("/correspondences");
|
onSuccess: () => router.push(`/correspondences/${id}`)
|
||||||
},
|
});
|
||||||
});
|
} else {
|
||||||
|
// CREATE Mode
|
||||||
|
createMutation.mutate(payload, {
|
||||||
|
onSuccess: () => router.push("/correspondences"),
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -103,6 +119,7 @@ export function CorrespondenceForm() {
|
|||||||
<Label>From Organization *</Label>
|
<Label>From Organization *</Label>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(v) => setValue("fromOrganizationId", parseInt(v))}
|
onValueChange={(v) => setValue("fromOrganizationId", parseInt(v))}
|
||||||
|
value={fromOrgId ? String(fromOrgId) : undefined}
|
||||||
disabled={isLoadingOrgs}
|
disabled={isLoadingOrgs}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -125,6 +142,7 @@ export function CorrespondenceForm() {
|
|||||||
<Label>To Organization *</Label>
|
<Label>To Organization *</Label>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(v) => setValue("toOrganizationId", parseInt(v))}
|
onValueChange={(v) => setValue("toOrganizationId", parseInt(v))}
|
||||||
|
value={toOrgId ? String(toOrgId) : undefined}
|
||||||
disabled={isLoadingOrgs}
|
disabled={isLoadingOrgs}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -177,22 +195,24 @@ export function CorrespondenceForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{!initialData && (
|
||||||
<Label>Attachments</Label>
|
<div className="space-y-2">
|
||||||
<FileUpload
|
<Label>Attachments</Label>
|
||||||
onFilesSelected={(files) => setValue("attachments", files)}
|
<FileUpload
|
||||||
maxFiles={10}
|
onFilesSelected={(files) => setValue("attachments", files)}
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
|
maxFiles={10}
|
||||||
/>
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 pt-6 border-t">
|
<div className="flex justify-end gap-4 pt-6 border-t">
|
||||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Create Correspondence
|
{id ? "Update Correspondence" : "Create Correspondence"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,48 +1,49 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Correspondence } from "@/types/correspondence";
|
import { CorrespondenceRevision } from "@/types/correspondence";
|
||||||
import { DataTable } from "@/components/common/data-table";
|
import { DataTable } from "@/components/common/data-table";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { StatusBadge } from "@/components/common/status-badge";
|
import { StatusBadge } from "@/components/common/status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Eye, Edit } from "lucide-react";
|
import { Eye, Edit, FileText } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
interface CorrespondenceListProps {
|
interface CorrespondenceListProps {
|
||||||
data?: {
|
data: CorrespondenceRevision[];
|
||||||
items: Correspondence[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
||||||
const columns: ColumnDef<Correspondence>[] = [
|
const columns: ColumnDef<CorrespondenceRevision>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "documentNumber",
|
accessorKey: "correspondence.correspondenceNumber",
|
||||||
header: "Document No.",
|
header: "Document No.",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">{row.getValue("documentNumber")}</span>
|
<span className="font-medium">{row.original.correspondence?.correspondenceNumber}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "subject",
|
accessorKey: "revisionLabel",
|
||||||
|
header: "Rev",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.revisionLabel || row.original.revisionNumber}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
header: "Subject",
|
header: "Subject",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
|
<div className="max-w-[300px] truncate" title={row.original.title}>
|
||||||
{row.getValue("subject")}
|
{row.original.title}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "fromOrganization.orgName",
|
accessorKey: "correspondence.originator.orgName",
|
||||||
header: "From",
|
header: "From",
|
||||||
},
|
cell: ({ row }) => (
|
||||||
{
|
<span>{row.original.correspondence?.originator?.orgName || '-'}</span>
|
||||||
accessorKey: "toOrganization.orgName",
|
),
|
||||||
header: "To",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -50,23 +51,45 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
|||||||
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status.statusName",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
cell: ({ row }) => <StatusBadge status={row.original.status?.statusCode || "UNKNOWN"} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original;
|
const item = row.original;
|
||||||
|
// Edit/View link goes to the DOCUMENT detail (correspondence.id)
|
||||||
|
// Ideally we might pass ?revId=item.id to view specific revision, but detail page defaults to latest.
|
||||||
|
// For editing, we edit the document.
|
||||||
|
const docId = item.correspondence.id;
|
||||||
|
const statusCode = item.status?.statusCode;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href={`/correspondences/${row.original.correspondenceId}`}>
|
<Link href={`/correspondences/${docId}`}>
|
||||||
<Button variant="ghost" size="icon" title="View">
|
<Button variant="ghost" size="icon" title="View Details">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{item.status === "DRAFT" && (
|
<Button variant="ghost" size="icon" title="View File" onClick={() => {
|
||||||
<Link href={`/correspondences/${row.original.correspondenceId}/edit`}>
|
const attachments = item.attachments; // Now we are on Revision, so attachments might be here if joined
|
||||||
|
if (attachments && attachments.length > 0 && attachments[0].url) {
|
||||||
|
window.open(attachments[0].url, '_blank');
|
||||||
|
} else {
|
||||||
|
// Fallback check if attachments are on details json inside revision
|
||||||
|
// or if we simply didn't join them yet.
|
||||||
|
// Current Backend join: leftJoinAndSelect('rev.status', 'status') doesn't join attachments explicitly but maybe relation exists?
|
||||||
|
// Wait, checking Entity... CorrespondenceRevision does NOT have attachments relation in code snippet provided earlier.
|
||||||
|
// It might be in 'details' JSON or implied.
|
||||||
|
// Just Alert for now as per previous logic.
|
||||||
|
alert("ไม่พบไฟล์แนบ (No file attached)");
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{statusCode === "DRAFT" && (
|
||||||
|
<Link href={`/correspondences/${docId}/edit`}>
|
||||||
<Button variant="ghost" size="icon" title="Edit">
|
<Button variant="ghost" size="icon" title="Edit">
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -80,8 +103,7 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DataTable columns={columns} data={data?.items || []} />
|
<DataTable columns={columns} data={data || []} />
|
||||||
{/* Pagination component would go here, receiving props from data */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ export function StatsCards({ stats, isLoading }: StatsCardsProps) {
|
|||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: "Total Correspondences",
|
title: "Total Correspondences",
|
||||||
value: stats.correspondences,
|
value: stats.totalDocuments,
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
color: "text-blue-600",
|
color: "text-blue-600",
|
||||||
bgColor: "bg-blue-50",
|
bgColor: "bg-blue-50",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active RFAs",
|
title: "Active RFAs",
|
||||||
value: stats.rfas,
|
value: stats.totalRfas,
|
||||||
icon: Clipboard,
|
icon: Clipboard,
|
||||||
color: "text-purple-600",
|
color: "text-purple-600",
|
||||||
bgColor: "bg-purple-50",
|
bgColor: "bg-purple-50",
|
||||||
@@ -43,7 +43,7 @@ export function StatsCards({ stats, isLoading }: StatsCardsProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pending Approvals",
|
title: "Pending Approvals",
|
||||||
value: stats.pending,
|
value: stats.pendingApprovals,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "text-orange-600",
|
color: "text-orange-600",
|
||||||
bgColor: "bg-orange-50",
|
bgColor: "bg-orange-50",
|
||||||
|
|||||||
@@ -5,17 +5,12 @@ import { DataTable } from "@/components/common/data-table";
|
|||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { StatusBadge } from "@/components/common/status-badge";
|
import { StatusBadge } from "@/components/common/status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Eye } from "lucide-react";
|
import { Eye, Edit, FileText } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
interface RFAListProps {
|
interface RFAListProps {
|
||||||
data: {
|
data: RFA[];
|
||||||
items: RFA[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RFAList({ data }: RFAListProps) {
|
export function RFAList({ data }: RFAListProps) {
|
||||||
@@ -25,48 +20,87 @@ export function RFAList({ data }: RFAListProps) {
|
|||||||
{
|
{
|
||||||
accessorKey: "rfa_number",
|
accessorKey: "rfa_number",
|
||||||
header: "RFA No.",
|
header: "RFA No.",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<span className="font-medium">{row.getValue("rfa_number")}</span>
|
const rev = row.original.revisions?.[0];
|
||||||
),
|
return <span className="font-medium">{rev?.correspondence?.correspondenceNumber || '-'}</span>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "subject",
|
accessorKey: "subject",
|
||||||
header: "Subject",
|
header: "Subject",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
|
const rev = row.original.revisions?.[0];
|
||||||
{row.getValue("subject")}
|
return (
|
||||||
</div>
|
<div className="max-w-[300px] truncate" title={rev?.title}>
|
||||||
),
|
{rev?.title || '-'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "contract_name",
|
accessorKey: "contract_name", // AccessorKey can be anything if we provide cell
|
||||||
header: "Contract",
|
header: "Contract",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const rev = row.original.revisions?.[0];
|
||||||
|
return <span>{rev?.correspondence?.project?.projectName || '-'}</span>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "discipline_name",
|
accessorKey: "discipline_name",
|
||||||
header: "Discipline",
|
header: "Discipline",
|
||||||
|
cell: ({ row }) => <span>{row.original.discipline?.name || '-'}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: "Created",
|
header: "Created",
|
||||||
cell: ({ row }) => format(new Date(row.getValue("createdAt")), "dd MMM yyyy"),
|
cell: ({ row }) => {
|
||||||
|
const date = row.original.revisions?.[0]?.correspondence?.createdAt;
|
||||||
|
return date ? format(new Date(date), "dd MMM yyyy") : '-';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.revisions?.[0]?.statusCode?.statusName || row.original.revisions?.[0]?.statusCode?.statusCode;
|
||||||
|
return <StatusBadge status={status || 'Unknown'} />;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original;
|
const item = row.original;
|
||||||
|
|
||||||
|
const handleViewFile = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Logic to find first attachment: Check items -> shopDrawingRevision -> attachments
|
||||||
|
const firstAttachment = item.revisions?.[0]?.items?.[0]?.shopDrawingRevision?.attachments?.[0];
|
||||||
|
if (firstAttachment?.url) {
|
||||||
|
window.open(firstAttachment.url, '_blank');
|
||||||
|
} else {
|
||||||
|
// Use alert or toast. Assuming toast is available or use generic alert for now if toast not imported
|
||||||
|
// But rfa.service.ts in use-rfa.ts uses 'sonner', so 'sonner' is likely available.
|
||||||
|
// I will try to use toast from 'sonner' if I import it, or just window.alert for safety.
|
||||||
|
// User said "หน้าต่างแจ้งเตือน" -> Alert window.
|
||||||
|
alert("ไม่พบไฟล์แนบ (No file attached)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href={`/rfas/${row.original.rfaId}`}>
|
<Link href={`/rfas/${row.original.id}`}>
|
||||||
<Button variant="ghost" size="icon" title="View">
|
<Button variant="ghost" size="icon" title="View Details">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Button variant="ghost" size="icon" title="View File" onClick={handleViewFile}>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Link href={`/rfas/${row.original.id}/edit`}>
|
||||||
|
<Button variant="ghost" size="icon" title="Edit">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -75,7 +109,7 @@ export function RFAList({ data }: RFAListProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DataTable columns={columns} data={data?.items || []} />
|
<DataTable columns={columns} data={data || []} />
|
||||||
{/* Pagination component would go here */}
|
{/* Pagination component would go here */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
15
frontend/components/ui/skeleton.tsx
Normal file
15
frontend/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -17,7 +17,7 @@ export const correspondenceService = {
|
|||||||
|
|
||||||
getById: async (id: string | number) => {
|
getById: async (id: string | number) => {
|
||||||
const response = await apiClient.get(`/correspondences/${id}`);
|
const response = await apiClient.get(`/correspondences/${id}`);
|
||||||
return response.data;
|
return response.data.data; // Unwrap NestJS Interceptor 'data' wrapper
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (data: CreateCorrespondenceDto) => {
|
create: async (data: CreateCorrespondenceDto) => {
|
||||||
|
|||||||
@@ -13,21 +13,51 @@ export interface Attachment {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Correspondence {
|
// Used in List View mainly
|
||||||
correspondenceId: number;
|
export interface CorrespondenceRevision {
|
||||||
documentNumber: string;
|
id: number;
|
||||||
subject: string;
|
revisionNumber: number;
|
||||||
|
revisionLabel?: string; // e.g. "A", "00"
|
||||||
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status: "DRAFT" | "PENDING" | "IN_REVIEW" | "APPROVED" | "REJECTED" | "CLOSED";
|
isCurrent: boolean;
|
||||||
importance: "NORMAL" | "HIGH" | "URGENT";
|
status?: {
|
||||||
createdAt: string;
|
id: number;
|
||||||
updatedAt: string;
|
statusCode: string;
|
||||||
fromOrganizationId: number;
|
statusName: string;
|
||||||
toOrganizationId: number;
|
};
|
||||||
fromOrganization?: Organization;
|
details?: any;
|
||||||
toOrganization?: Organization;
|
|
||||||
documentTypeId: number;
|
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
// Nested Relation from Backend Refactor
|
||||||
|
correspondence: {
|
||||||
|
id: number;
|
||||||
|
correspondenceNumber: string;
|
||||||
|
projectId: number;
|
||||||
|
originatorId?: number;
|
||||||
|
isInternal: boolean;
|
||||||
|
originator?: Organization;
|
||||||
|
project?: { id: number; projectName: string; projectCode: string };
|
||||||
|
type?: { id: number; typeName: string; typeCode: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep explicit Correspondence for Detail View if needed, or merge concepts
|
||||||
|
export interface Correspondence {
|
||||||
|
id: number;
|
||||||
|
correspondenceNumber: string;
|
||||||
|
projectId: number;
|
||||||
|
originatorId?: number;
|
||||||
|
correspondenceTypeId: number;
|
||||||
|
isInternal: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
originator?: Organization;
|
||||||
|
project?: { id: number; projectName: string; projectCode: string };
|
||||||
|
type?: { id: number; typeName: string; typeCode: string };
|
||||||
|
revisions?: CorrespondenceRevision[]; // Nested revisions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCorrespondenceDto {
|
export interface CreateCorrespondenceDto {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
correspondences: number;
|
totalDocuments: number;
|
||||||
rfas: number;
|
documentsThisMonth: number;
|
||||||
|
pendingApprovals: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
pending: number;
|
totalRfas: number;
|
||||||
|
totalCirculations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityLog {
|
export interface ActivityLog {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface SearchCorrespondenceDto {
|
|||||||
typeId?: number; // กรองตามประเภทเอกสาร
|
typeId?: number; // กรองตามประเภทเอกสาร
|
||||||
projectId?: number; // กรองตามโครงการ
|
projectId?: number; // กรองตามโครงการ
|
||||||
statusId?: number; // กรองตามสถานะ (จาก Revision ปัจจุบัน)
|
statusId?: number; // กรองตามสถานะ (จาก Revision ปัจจุบัน)
|
||||||
|
revisionStatus?: 'CURRENT' | 'ALL' | 'OLD'; // กรองตามสถานะ Revision
|
||||||
|
|
||||||
// เพิ่มเติมสำหรับการแบ่งหน้า (Pagination)
|
// เพิ่มเติมสำหรับการแบ่งหน้า (Pagination)
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
@@ -52,4 +52,7 @@ export interface SearchRfaDto {
|
|||||||
|
|
||||||
/** จำนวนต่อหน้า (Default: 20) */
|
/** จำนวนต่อหน้า (Default: 20) */
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|
||||||
|
/** Revision Status Filter */
|
||||||
|
revisionStatus?: 'CURRENT' | 'ALL' | 'OLD';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,28 @@ export interface RFAItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RFA {
|
export interface RFA {
|
||||||
rfaId: number;
|
id: number;
|
||||||
rfaNumber: string;
|
rfaTypeId: number;
|
||||||
subject: string;
|
createdBy: number;
|
||||||
description?: string;
|
disciplineId?: number;
|
||||||
contractId: number;
|
revisions: {
|
||||||
disciplineId: number;
|
items?: {
|
||||||
status: "DRAFT" | "PENDING" | "IN_REVIEW" | "APPROVED" | "REJECTED" | "CLOSED";
|
shopDrawingRevision?: {
|
||||||
createdAt: string;
|
attachments?: { id: number; url: string; name: string }[]
|
||||||
updatedAt: string;
|
}
|
||||||
items: RFAItem[];
|
}[];
|
||||||
// Mock fields for display
|
}[];
|
||||||
|
discipline?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
// Deprecated/Mapped fields (keep optional if frontend uses them elsewhere)
|
||||||
|
rfaId?: number;
|
||||||
|
rfaNumber?: string;
|
||||||
|
subject?: string;
|
||||||
|
status?: string;
|
||||||
|
createdAt?: string;
|
||||||
contractName?: string;
|
contractName?: string;
|
||||||
disciplineName?: string;
|
disciplineName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
# Frontend Progress Report
|
# Frontend Progress Report
|
||||||
|
|
||||||
**Date:** 2025-12-10
|
**Date:** 2025-12-11
|
||||||
**Status:** ✅ **Complete (~100%)**
|
**Status:** ✅ **Complete (~100%)**
|
||||||
|
|
||||||
## 📊 Overview
|
## 📊 Overview
|
||||||
|
|
||||||
| Task ID | Title | Status | Completion % | Notes |
|
| Task ID | Title | Status | Completion % | Notes |
|
||||||
| --------------- | ------------------------- | ---------- | ------------ | ---------------------------------------------------------------- |
|
| --------------- | ------------------------- | ---------- | ------------ | ------------------------------------------------------------------- |
|
||||||
| **TASK-FE-001** | Frontend Setup | ✅ **Done** | 100% | Project structure, Tailwind, Shadcn/UI initialized. |
|
| **TASK-FE-001** | Frontend Setup | ✅ **Done** | 100% | Project structure, Tailwind, Shadcn/UI initialized. |
|
||||||
| **TASK-FE-002** | Auth UI | ✅ **Done** | 100% | Store, RBAC, Login UI, Refresh Token, Session Sync implemented. |
|
| **TASK-FE-002** | Auth UI | ✅ **Done** | 100% | Store, RBAC, Login UI, Refresh Token, Session Sync implemented. |
|
||||||
| **TASK-FE-003** | Layout & Navigation | ✅ **Done** | 100% | Sidebar, Header, Layouts are implemented. |
|
| **TASK-FE-003** | Layout & Navigation | ✅ **Done** | 100% | Sidebar, Header, Layouts are implemented. |
|
||||||
| **TASK-FE-004** | Correspondence UI | ✅ **Done** | 100% | Integrated with Backend API (List/Create/Hooks). |
|
| **TASK-FE-004** | Correspondence UI | ✅ **Done** | 100% | Refactored to Revision-based List. Edit/View fully functional. |
|
||||||
| **TASK-FE-005** | Common Components | ✅ **Done** | 100% | Data tables, File upload, etc. implemented. |
|
| **TASK-FE-005** | Common Components | ✅ **Done** | 100% | Data tables, File upload, etc. implemented. |
|
||||||
| **TASK-FE-006** | RFA UI | ✅ **Done** | 100% | Integrated with Backend (Workflow/Create/List). |
|
| **TASK-FE-006** | RFA UI | ✅ **Done** | 100% | Integrated with Backend (Workflow/Create/List). |
|
||||||
| **TASK-FE-007** | Drawing UI | ✅ **Done** | 100% | Drawings List & Upload integrated with Real API (Contract/Shop). |
|
| **TASK-FE-007** | Drawing UI | ✅ **Done** | 100% | Drawings List & Upload integrated with Real API (Contract/Shop). |
|
||||||
| **TASK-FE-008** | Search UI | ✅ **Done** | 100% | Global Search & Advanced Search with Real API. |
|
| **TASK-FE-008** | Search UI | ✅ **Done** | 100% | Global Search & Advanced Search with Real API. |
|
||||||
| **TASK-FE-009** | Dashboard & Notifications | ✅ **Done** | 100% | Statistics, Activity Feed, and Notifications integrated. |
|
| **TASK-FE-009** | Dashboard & Notifications | ✅ **Done** | 100% | Statistics, Activity Feed, and Notifications integrated. |
|
||||||
| **TASK-FE-010** | Admin Panel | ✅ **Done** | 100% | Users (Polish: LineID/Org added), Audit Logs, Orgs implemented. |
|
| **TASK-FE-010** | Admin Panel | ✅ **Done** | 100% | Users (UX: Skeleton/Dialogs), Audit Logs, Orgs (UX refactor). |
|
||||||
| **TASK-FE-011** | Workflow Config UI | ✅ **Done** | 100% | List/Create/Edit pages, DSL Editor, Visual Builder implemented. |
|
| **TASK-FE-011** | Workflow Config UI | ✅ **Done** | 100% | List/Create/Edit pages, DSL Editor, Visual Builder implemented. |
|
||||||
| **TASK-FE-012** | Numbering Config UI | ✅ **Done** | 100% | Template Editor, Tester, Sequence Viewer integrated. |
|
| **TASK-FE-012** | Numbering Config UI | ✅ **Done** | 100% | Template Editor, Tester, Sequence Viewer integrated. |
|
||||||
| **TASK-FE-013** | Circulation & Transmittal | ✅ **Done** | 100% | Circulation and Transmittal modules implemented with DataTable. |
|
| **TASK-FE-013** | Circulation & Transmittal | ✅ **Done** | 100% | Circulation and Transmittal modules implemented with DataTable. |
|
||||||
| **TASK-FE-014** | Reference Data UI | ✅ **Done** | 100% | CRUD pages for Disciplines, RFA/Corresp Types, Drawing Cats. |
|
| **TASK-FE-014** | Reference Data UI | ✅ **Done** | 100% | Generic CRUD Table refactored (Skeleton/Dialogs). All pages linked. |
|
||||||
| **TASK-FE-015** | Security Admin UI | ✅ **Done** | 100% | RBAC Matrix, Roles, Active Sessions, System Logs implemented. |
|
| **TASK-FE-015** | Security Admin UI | ✅ **Done** | 100% | RBAC Matrix, Roles, Active Sessions, System Logs implemented. |
|
||||||
|
|
||||||
## 🛠 Detailed Status by Component
|
## 🛠 Detailed Status by Component
|
||||||
|
|
||||||
@@ -42,12 +42,12 @@
|
|||||||
- **Pending (Backend/Integration):**
|
- **Pending (Backend/Integration):**
|
||||||
- Backend needs to map `assignments` to flatten `role` field for simpler consumption (currently defaults to "User").
|
- Backend needs to map `assignments` to flatten `role` field for simpler consumption (currently defaults to "User").
|
||||||
|
|
||||||
### 3. Business Modules (🚧 In Progress)
|
### 3. Business Modules (✅ Completed)
|
||||||
|
|
||||||
- **Correspondences:** List and Form UI components exist.
|
- **Correspondences:** Refactored List to show "One Row per Revision". Detail and Edit pages fully integrated with Backend API.
|
||||||
- **RFAs:** List and Form UI components exist.
|
- **RFAs:** List and Form UI components integrated.
|
||||||
- **Drawings:** Basic structure exists.
|
- **Drawings:** List and Upload integrated.
|
||||||
- **Needs:** Full integration with Backend APIs using `tanstack-query` and correct DTO mapping.
|
- **Integration:** All modules using `tanstack-query` and aligned with Backend DTOs.
|
||||||
|
|
||||||
## 📅 Next Priorities
|
## 📅 Next Priorities
|
||||||
|
|
||||||
|
|||||||
36
specs/09-history/2025-12-11-admin-ux-refactor.md
Normal file
36
specs/09-history/2025-12-11-admin-ux-refactor.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Admin Panel UX Refactoring (2025-12-11)
|
||||||
|
|
||||||
|
**Objectives:**
|
||||||
|
- Standardize UX across Admin modules (Loading Skeletons, Alert Dialogs).
|
||||||
|
- Fix specific display bugs in Reference Data.
|
||||||
|
- Improve Admin Dashboard.
|
||||||
|
|
||||||
|
**Achievements:**
|
||||||
|
1. **Dashboard Upgrade:**
|
||||||
|
- Replaced `/admin` redirect with a proper Dashboard page showing stats and quick links.
|
||||||
|
- Added `Skeleton` loading for stats.
|
||||||
|
|
||||||
|
2. **Consistency Improvements:**
|
||||||
|
- **Modules:** Organizations, Users, Projects, Contracts.
|
||||||
|
- **Changes:**
|
||||||
|
- Replaced "Loading..." text with `Skeleton` rows.
|
||||||
|
- Replaced `window.confirm()` with `AlertDialog` (Shadcn UI).
|
||||||
|
- Fixed `any` type violations in Users, Projects, Contracts.
|
||||||
|
|
||||||
|
3. **Reference Data Overhaul:**
|
||||||
|
- Refactored `GenericCrudTable` to include Skeleton loading and AlertDialogs natively.
|
||||||
|
- Applied to all reference pages: Correspondence Types, Disciplines, Drawing Categories, RFA Types, Tags.
|
||||||
|
- **Fixed Bug:** Missing "Drawing Categories" link in Reference Dashboard.
|
||||||
|
- **Fixed Bug:** "Drawing Categories" page displaying incorrect columns (fixed DTO matching).
|
||||||
|
|
||||||
|
**Modified Files:**
|
||||||
|
- `frontend/app/(admin)/admin/page.tsx`
|
||||||
|
- `frontend/app/(admin)/admin/organizations/page.tsx`
|
||||||
|
- `frontend/app/(admin)/admin/users/page.tsx`
|
||||||
|
- `frontend/app/(admin)/admin/projects/page.tsx`
|
||||||
|
- `frontend/app/(admin)/admin/contracts/page.tsx`
|
||||||
|
- `frontend/app/(admin)/admin/reference/page.tsx`
|
||||||
|
- `frontend/app/(admin)/admin/reference/drawing-categories/page.tsx`
|
||||||
|
- `frontend/components/admin/organization-dialog.tsx` (Minor)
|
||||||
|
- `frontend/components/admin/reference/generic-crud-table.tsx`
|
||||||
|
- `frontend/components/ui/skeleton.tsx` (New)
|
||||||
39
specs/09-history/2025-12-11-correspondence-refactor.md
Normal file
39
specs/09-history/2025-12-11-correspondence-refactor.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Correspondence Module Refactoring Report
|
||||||
|
|
||||||
|
**Date:** 2025-12-11
|
||||||
|
**Objective:** Fix data display issues and align Correspondence Module with user requirements (Revision-based List).
|
||||||
|
|
||||||
|
## 🛠 Fixes & Changes
|
||||||
|
|
||||||
|
### 1. Revision-Based List View
|
||||||
|
- **Issue:** The Correspondence List was displaying one row per Document, hiding revision history.
|
||||||
|
- **Fix:** Refactored `CorrespondenceService.findAll` to query `CorrespondenceRevision` as the primary entity.
|
||||||
|
- **Outcome:** The list now displays every revision (e.g., Doc-001 Rev A, Doc-001 Rev B) as separate rows. Added "Rev" column to the UI.
|
||||||
|
|
||||||
|
### 2. Correspondence Detail Page
|
||||||
|
- **Issue:** Detail page was not displaying Subject/Description correctly (showing "-") because it wasn't resolving the `currentRevision` correctly or receiving unwrapped data.
|
||||||
|
- **Fix:**
|
||||||
|
- Updated `CorrespondenceDetail` to explicitly try finding `isCurrent` revision or fallback to index 0.
|
||||||
|
- Updated `useCorrespondence` (via `correspondence.service.ts`) to correctly unwrap the NestJS Interceptor response `{ data: { ... } }`.
|
||||||
|
- **Outcome:** Detail page now correctly shows Subject, Description, and Status from the current revision.
|
||||||
|
|
||||||
|
### 3. Edit Functionality
|
||||||
|
- **Issue:** Clicking "Edit" led to a 404/Blank page.
|
||||||
|
- **Fix:**
|
||||||
|
- Created `app/(dashboard)/correspondences/[id]/edit/page.tsx`.
|
||||||
|
- Refactored `CorrespondenceForm` to accept `initialData` and supporting "Update" mode (switching between `createMutation` and `updateMutation`).
|
||||||
|
- **Outcome:** Users can now edit existing DRAFT correspondences.
|
||||||
|
|
||||||
|
## 📂 Modified Files
|
||||||
|
- `backend/src/modules/correspondence/correspondence.service.ts`
|
||||||
|
- `frontend/types/correspondence.ts`
|
||||||
|
- `frontend/components/correspondences/list.tsx`
|
||||||
|
- `frontend/components/correspondences/detail.tsx`
|
||||||
|
- `frontend/components/correspondences/form.tsx`
|
||||||
|
- `frontend/lib/services/correspondence.service.ts`
|
||||||
|
- `frontend/app/(dashboard)/correspondences/[id]/edit/page.tsx` (Created)
|
||||||
|
|
||||||
|
## ✅ Verification
|
||||||
|
- Validated List View shows revisions.
|
||||||
|
- Validated Detail View loads data.
|
||||||
|
- Validated Edit Page loads data and submits updates.
|
||||||
Reference in New Issue
Block a user