251208:1625 Frontend: to be complete admin panel, Backend: tobe recheck all task
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-08 16:25:56 +07:00
parent dcd126d704
commit 863a727756
64 changed files with 5956 additions and 1256 deletions

View File

@@ -34,9 +34,8 @@ export class RbacGuard implements CanActivate {
}
// 3. (สำคัญ) ดึงสิทธิ์ทั้งหมดของ User คนนี้จาก Database
// เราต้องเขียนฟังก์ชัน getUserPermissions ใน UserService เพิ่ม (เดี๋ยวพาทำ)
const userPermissions = await this.userService.getUserPermissions(
user.userId
user.user_id // ✅ FIX: ใช้ user_id ตาม Entity field name
);
// 4. ตรวจสอบว่ามีสิทธิ์ที่ต้องการไหม? (User ต้องมีครบทุกสิทธิ์)

View File

@@ -11,6 +11,7 @@ export interface Response<T> {
statusCode: number;
message: string;
data: T;
meta?: any;
}
@Injectable()
@@ -19,14 +20,29 @@ export class TransformInterceptor<T>
{
intercept(
context: ExecutionContext,
next: CallHandler,
next: CallHandler
): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
statusCode: context.switchToHttp().getResponse().statusCode,
message: data?.message || 'Success', // ถ้า data มี message ให้ใช้ ถ้าไม่มีใช้ 'Success'
data: data?.result || data, // รองรับกรณีส่ง object ที่มี key result มา
})),
map((data: any) => {
const response = context.switchToHttp().getResponse();
// Handle Pagination Response (Standardize)
// ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา
if (data && data.data && data.meta) {
return {
statusCode: response.statusCode,
message: data.message || 'Success',
data: data.data,
meta: data.meta,
};
}
return {
statusCode: response.statusCode,
message: data?.message || 'Success',
data: data?.result || data,
};
})
);
}
}

View File

@@ -86,7 +86,7 @@ async function bootstrap() {
// 🚀 7. Start Server
const port = configService.get<number>('PORT') || 3001;
await app.listen(port);
await app.listen(port, '0.0.0.0');
logger.log(`Application is running on: ${await app.getUrl()}/api`);
logger.log(`Swagger UI is available at: ${await app.getUrl()}/docs`);

View File

@@ -5,28 +5,37 @@ import {
ManyToMany,
JoinTable,
} from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity';
@Entity('permissions')
export class Permission {
@PrimaryGeneratedColumn()
export class Permission extends BaseEntity {
@PrimaryGeneratedColumn({ name: 'permission_id' })
id!: number;
@Column({ name: 'permission_code', length: 50, unique: true })
permissionCode!: string;
@Column({ name: 'permission_name', length: 100, unique: true })
permissionName!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description!: string;
@Column({ name: 'resource', length: 50 })
resource!: string;
@Column({ name: 'module', length: 50, nullable: true })
module?: string;
@Column({ name: 'action', length: 50 })
action!: string;
@Column({
name: 'scope_level',
type: 'enum',
enum: ['GLOBAL', 'ORG', 'PROJECT'],
nullable: true,
})
scopeLevel?: 'GLOBAL' | 'ORG' | 'PROJECT';
@Column({ name: 'is_active', default: true, type: 'tinyint' })
isActive!: boolean;
}
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn()
export class Role extends BaseEntity {
@PrimaryGeneratedColumn({ name: 'role_id' })
id!: number;
@Column({ name: 'role_name', length: 50, unique: true })
@@ -35,6 +44,16 @@ export class Role {
@Column({ name: 'description', type: 'text', nullable: true })
description!: string;
@Column({
type: 'enum',
enum: ['Global', 'Organization', 'Project', 'Contract'],
default: 'Global',
})
scope!: 'Global' | 'Organization' | 'Project' | 'Contract';
@Column({ name: 'is_system', default: false })
isSystem!: boolean;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permissions',

View File

@@ -21,4 +21,15 @@ export class SearchCorrespondenceDto {
@Type(() => Number)
@IsInt()
statusId?: number;
// Pagination
@IsOptional()
@Type(() => Number)
@IsInt()
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
limit?: number;
}

View File

@@ -29,15 +29,17 @@ import { User } from '../user/entities/user.entity';
@Controller('drawings/contract')
export class ContractDrawingController {
constructor(
private readonly contractDrawingService: ContractDrawingService,
private readonly contractDrawingService: ContractDrawingService
) {}
// Force rebuild for DTO changes
@Post()
@ApiOperation({ summary: 'Create new Contract Drawing' })
@RequirePermission('drawing.create') // สิทธิ์ ID 39: สร้าง/แก้ไขข้อมูลแบบ
create(
@Body() createDto: CreateContractDrawingDto,
@CurrentUser() user: User,
@CurrentUser() user: User
) {
return this.contractDrawingService.create(createDto, user);
}
@@ -62,7 +64,7 @@ export class ContractDrawingController {
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateContractDrawingDto,
@CurrentUser() user: User,
@CurrentUser() user: User
) {
return this.contractDrawingService.update(id, updateDto, user);
}

View File

@@ -31,7 +31,7 @@ export class ContractDrawingService {
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private fileStorageService: FileStorageService,
private dataSource: DataSource,
private dataSource: DataSource
) {}
/**
@@ -51,7 +51,7 @@ export class ContractDrawingService {
if (exists) {
throw new ConflictException(
`Contract Drawing No. "${createDto.contractDrawingNo}" already exists in this project.`,
`Contract Drawing No. "${createDto.contractDrawingNo}" already exists in this project.`
);
}
@@ -85,7 +85,7 @@ export class ContractDrawingService {
if (createDto.attachmentIds?.length) {
// ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง
await this.fileStorageService.commit(
createDto.attachmentIds.map(String),
createDto.attachmentIds.map(String)
);
}
@@ -95,7 +95,7 @@ export class ContractDrawingService {
await queryRunner.rollbackTransaction();
// ✅ FIX TS18046: Cast err เป็น Error
this.logger.error(
`Failed to create contract drawing: ${(err as Error).message}`,
`Failed to create contract drawing: ${(err as Error).message}`
);
throw err;
} finally {
@@ -114,7 +114,7 @@ export class ContractDrawingService {
subCategoryId,
search,
page = 1,
pageSize = 20,
limit = 20,
} = searchDto;
const query = this.drawingRepo
@@ -143,14 +143,14 @@ export class ContractDrawingService {
qb.where('drawing.contractDrawingNo LIKE :search', {
search: `%${search}%`,
}).orWhere('drawing.title LIKE :search', { search: `%${search}%` });
}),
})
);
}
query.orderBy('drawing.contractDrawingNo', 'ASC');
const skip = (page - 1) * pageSize;
query.skip(skip).take(pageSize);
const skip = (page - 1) * limit;
query.skip(skip).take(limit);
const [items, total] = await query.getManyAndCount();
@@ -159,8 +159,8 @@ export class ContractDrawingService {
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
limit,
totalPages: Math.ceil(total / limit),
},
};
}
@@ -213,7 +213,7 @@ export class ContractDrawingService {
// Commit new files
// ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง
await this.fileStorageService.commit(
updateDto.attachmentIds.map(String),
updateDto.attachmentIds.map(String)
);
}
@@ -225,7 +225,7 @@ export class ContractDrawingService {
await queryRunner.rollbackTransaction();
// ✅ FIX TS18046: Cast err เป็น Error (Optional: Added logger here too for consistency)
this.logger.error(
`Failed to update contract drawing: ${(err as Error).message}`,
`Failed to update contract drawing: ${(err as Error).message}`
);
throw err;
} finally {

View File

@@ -29,5 +29,9 @@ export class SearchContractDrawingDto {
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize: number = 20; // มีค่า Default ไม่ต้องใส่ ! หรือ ?
limit: number = 20;
@IsOptional()
@IsString()
type?: string;
}

View File

@@ -28,5 +28,5 @@ export class SearchShopDrawingDto {
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize: number = 20; // มีค่า Default
limit: number = 20; // มีค่า Default
}

View File

@@ -208,10 +208,10 @@ export class ShopDrawingService {
const {
projectId,
mainCategoryId,
subCategoryId,
// subCategoryId, // Unused
search,
page = 1,
pageSize = 20,
limit = 20,
} = searchDto;
const query = this.shopDrawingRepo
@@ -225,10 +225,6 @@ export class ShopDrawingService {
query.andWhere('sd.mainCategoryId = :mainCategoryId', { mainCategoryId });
}
if (subCategoryId) {
query.andWhere('sd.subCategoryId = :subCategoryId', { subCategoryId });
}
if (search) {
query.andWhere(
new Brackets((qb) => {
@@ -241,8 +237,8 @@ export class ShopDrawingService {
query.orderBy('sd.updatedAt', 'DESC');
const skip = (page - 1) * pageSize;
query.skip(skip).take(pageSize);
const skip = (page - 1) * limit;
query.skip(skip).take(limit);
const [items, total] = await query.getManyAndCount();
@@ -262,8 +258,8 @@ export class ShopDrawingService {
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
limit,
totalPages: Math.ceil(total / limit),
},
};
}

View File

@@ -29,5 +29,5 @@ export class SearchRfaDto {
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize: number = 20;
limit: number = 20;
}

View File

@@ -6,6 +6,7 @@ import {
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
@@ -79,6 +80,14 @@ export class RfaController {
return this.rfaService.processAction(id, actionDto, user);
}
@Get()
@ApiOperation({ summary: 'List all RFAs with pagination' })
@ApiResponse({ status: 200, description: 'List of RFAs' })
@RequirePermission('document.view')
findAll(@Query() query: any) {
return this.rfaService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get RFA details with revisions and items' })
@ApiParam({ name: 'id', description: 'RFA ID' })

View File

@@ -230,6 +230,52 @@ export class RfaService {
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
async findAll(query: any) {
const { page = 1, limit = 20, projectId, status, search } = query;
const skip = (page - 1) * limit;
// Fix: Start query from Rfa entity instead of Correspondence,
// because Correspondence has no 'rfas' relation.
// [Force Rebuild]
const queryBuilder = this.rfaRepo
.createQueryBuilder('rfa')
.leftJoinAndSelect('rfa.revisions', 'rev')
.leftJoinAndSelect('rev.correspondence', 'corr')
.leftJoinAndSelect('rev.statusCode', 'status')
.where('rev.isCurrent = :isCurrent', { isCurrent: true });
if (projectId) {
queryBuilder.andWhere('corr.projectId = :projectId', { projectId });
}
if (status) {
queryBuilder.andWhere('status.statusCode = :status', { status });
}
if (search) {
queryBuilder.andWhere(
'(corr.correspondenceNumber LIKE :search OR rev.title LIKE :search)',
{ search: `%${search}%` }
);
}
const [items, total] = await queryBuilder
.orderBy('corr.createdAt', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(id: number) {
const rfa = await this.rfaRepo.findOne({
where: { id },

View File

@@ -30,5 +30,5 @@ export class SearchTransmittalDto {
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize: number = 20;
limit: number = 20;
}

View File

@@ -1,135 +1,193 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Edit, Eye, Loader2 } from "lucide-react";
import Link from "next/link";
import { NumberingTemplate } from "@/types/numbering";
import { numberingApi } from "@/lib/api/numbering";
import { TemplateTester } from "@/components/numbering/template-tester";
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Play } from 'lucide-react';
import { numberingApi, NumberingTemplate } from '@/lib/api/numbering';
import { TemplateEditor } from '@/components/numbering/template-editor';
import { SequenceViewer } from '@/components/numbering/sequence-viewer';
import { TemplateTester } from '@/components/numbering/template-tester';
import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const PROJECTS = [
{ id: '1', name: 'LCBP3' },
{ id: '2', name: 'LCBP3-Maintenance' },
];
export default function NumberingPage() {
const [selectedProjectId, setSelectedProjectId] = useState("1");
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [testerOpen, setTesterOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<NumberingTemplate | null>(null);
const [, setLoading] = useState(true);
useEffect(() => {
const fetchTemplates = async () => {
setLoading(true);
try {
// View states
const [isEditing, setIsEditing] = useState(false);
const [activeTemplate, setActiveTemplate] = useState<NumberingTemplate | undefined>(undefined);
const [isTesting, setIsTesting] = useState(false);
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
const selectedProjectName = PROJECTS.find(p => p.id === selectedProjectId)?.name || 'Unknown Project';
const loadTemplates = async () => {
setLoading(true);
try {
const data = await numberingApi.getTemplates();
setTemplates(data);
} catch (error) {
console.error("Failed to fetch templates", error);
} finally {
} catch {
toast.error("Failed to load templates");
} finally {
setLoading(false);
}
};
}
};
fetchTemplates();
useEffect(() => {
loadTemplates();
}, []);
const handleTest = (template: NumberingTemplate) => {
setSelectedTemplate(template);
setTesterOpen(true);
const handleEdit = (template?: NumberingTemplate) => {
setActiveTemplate(template);
setIsEditing(true);
};
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate(data);
toast.success(data.template_id ? "Template updated" : "Template created");
setIsEditing(false);
loadTemplates();
} catch {
toast.error("Failed to save template");
}
};
const handleTest = (template: NumberingTemplate) => {
setTestTemplate(template);
setIsTesting(true);
};
if (isEditing) {
return (
<div className="p-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4">
<TemplateEditor
template={activeTemplate}
projectId={Number(selectedProjectId)}
projectName={selectedProjectName}
onSave={handleSave}
onCancel={() => setIsEditing(false)}
/>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">
Document Numbering Configuration
<h1 className="text-3xl font-bold tracking-tight">
Document Numbering
</h1>
<p className="text-muted-foreground mt-1">
Manage document numbering templates and sequences
Manage numbering templates and sequences
</p>
</div>
<Link href="/admin/numbering/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</Link>
<div className="flex gap-2">
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent>
{PROJECTS.map(project => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => handleEdit(undefined)}>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-4">
{templates.map((template) => (
<Card key={template.template_id} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.document_type_name}
</h3>
<Badge variant="outline">{template.discipline_code || "All"}</Badge>
<Badge variant={template.is_active ? "default" : "secondary"} className={template.is_active ? "bg-green-600 hover:bg-green-700" : ""}>
{template.is_active ? "Active" : "Inactive"}
</Badge>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<h2 className="text-lg font-semibold">Templates - {selectedProjectName}</h2>
<div className="grid gap-4">
{templates
.filter(t => !t.project_id || t.project_id === Number(selectedProjectId)) // Show all if no project_id (legacy mock), or match
.map((template) => (
<Card key={template.template_id} className="p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.document_type_name}
</h3>
<Badge variant="outline" className="text-xs">
{PROJECTS.find(p => p.id === template.project_id?.toString())?.name || selectedProjectName}
</Badge>
{template.discipline_code && <Badge>{template.discipline_code}</Badge>}
<Badge variant={template.is_active ? 'default' : 'secondary'}>
{template.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="bg-muted rounded px-3 py-2 mb-3 font-mono text-sm">
{template.template_format}
</div>
<div className="bg-slate-100 dark:bg-slate-900 rounded px-3 py-2 mb-3 font-mono text-sm inline-block border">
{template.template_format}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Example: </span>
<span className="font-medium">
{template.example_number}
</span>
<div className="grid grid-cols-2 gap-4 text-sm mt-2">
<div>
<span className="text-muted-foreground">Example: </span>
<span className="font-medium font-mono text-green-600 dark:text-green-400">
{template.example_number}
</span>
</div>
<div>
<span className="text-muted-foreground">Reset: </span>
<span>
{template.reset_annually ? 'Annually' : 'Never'}
</span>
</div>
</div>
</div>
<div>
<span className="text-muted-foreground">Current Sequence: </span>
<span className="font-medium">
{template.current_number}
</span>
</div>
<div>
<span className="text-muted-foreground">Annual Reset: </span>
<span className="font-medium">
{template.reset_annually ? "Yes" : "No"}
</span>
</div>
<div>
<span className="text-muted-foreground">Padding: </span>
<span className="font-medium">
{template.padding_length} digits
</span>
</div>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/numbering/${template.template_id}/edit`}>
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => handleTest(template)}>
<Eye className="mr-2 h-4 w-4" />
Test & View
</Button>
</div>
</div>
</Card>
))}
</div>
)}
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(template)}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="outline" size="sm" onClick={() => handleTest(template)}>
<Play className="mr-2 h-4 w-4" />
Test
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
<div className="space-y-4">
{/* Sequence Viewer Sidebar */}
<SequenceViewer />
</div>
</div>
<TemplateTester
open={testerOpen}
onOpenChange={setTesterOpen}
template={selectedTemplate}
open={isTesting}
onOpenChange={setIsTesting}
template={testTemplate}
/>
</div>
);

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AdminPage() {
redirect('/admin/workflows');
}

View File

@@ -1,164 +1,206 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DSLEditor } from "@/components/workflows/dsl-editor";
import { VisualWorkflowBuilder } from "@/components/workflows/visual-builder";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { workflowApi } from "@/lib/api/workflows";
import { WorkflowType } from "@/types/workflow";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DSLEditor } from '@/components/workflows/dsl-editor';
import { VisualWorkflowBuilder } from '@/components/workflows/visual-builder';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { workflowApi } from '@/lib/api/workflows';
import { Workflow, CreateWorkflowDto } from '@/types/workflow';
import { toast } from 'sonner';
import { Save, ArrowLeft, Loader2 } from 'lucide-react';
import Link from 'next/link';
export default function WorkflowEditPage({ params }: { params: { id: string } }) {
export default function WorkflowEditPage() {
const params = useParams();
const router = useRouter();
const [loading, setLoading] = useState(true);
const id = params?.id === 'new' ? null : Number(params?.id);
const [loading, setLoading] = useState(!!id);
const [saving, setSaving] = useState(false);
const [workflowData, setWorkflowData] = useState({
workflow_name: "",
description: "",
workflow_type: "CORRESPONDENCE" as WorkflowType,
dsl_definition: "",
const [workflowData, setWorkflowData] = useState<Partial<Workflow>>({
workflow_name: '',
description: '',
workflow_type: 'CORRESPONDENCE',
dsl_definition: 'name: New Workflow\nversion: 1.0\nsteps: []',
is_active: true,
});
useEffect(() => {
const fetchWorkflow = async () => {
setLoading(true);
try {
const data = await workflowApi.getWorkflow(parseInt(params.id));
if (data) {
setWorkflowData({
workflow_name: data.workflow_name,
description: data.description,
workflow_type: data.workflow_type,
dsl_definition: data.dsl_definition,
});
}
} catch (error) {
console.error("Failed to fetch workflow", error);
} finally {
setLoading(false);
}
};
fetchWorkflow();
}, [params.id]);
if (id) {
const fetchWorkflow = async () => {
try {
const data = await workflowApi.getWorkflow(id);
if (data) {
setWorkflowData(data);
} else {
toast.error("Workflow not found");
router.push('/admin/workflows');
}
} catch (error) {
toast.error("Failed to load workflow");
console.error(error);
} finally {
setLoading(false);
}
};
fetchWorkflow();
}
}, [id, router]);
const handleSave = async () => {
if (!workflowData.workflow_name) {
toast.error("Workflow name is required");
return;
}
setSaving(true);
try {
await workflowApi.updateWorkflow(parseInt(params.id), workflowData);
router.push("/admin/workflows");
const dto: CreateWorkflowDto = {
workflow_name: workflowData.workflow_name || '',
description: workflowData.description || '',
workflow_type: workflowData.workflow_type || 'CORRESPONDENCE',
dsl_definition: workflowData.dsl_definition || '',
};
if (id) {
await workflowApi.updateWorkflow(id, dto);
toast.success("Workflow updated successfully");
} else {
await workflowApi.createWorkflow(dto);
toast.success("Workflow created successfully");
router.push('/admin/workflows');
}
} catch (error) {
console.error("Failed to save workflow", error);
alert("Failed to save workflow");
toast.error("Failed to save workflow");
console.error(error);
} finally {
setSaving(false);
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
return (
<div className="flex items-center justify-center h-screen">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Edit Workflow</h1>
<div className="flex items-center gap-4">
<Link href="/admin/workflows">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">{id ? 'Edit Workflow' : 'New Workflow'}</h1>
<p className="text-muted-foreground">{id ? `Version ${workflowData.version}` : 'Create a new workflow definition'}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>Cancel</Button>
<Link href="/admin/workflows">
<Button variant="outline">Cancel</Button>
</Link>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Workflow
<Save className="mr-2 h-4 w-4" />
{id ? 'Save Changes' : 'Create Workflow'}
</Button>
</div>
</div>
<Card className="p-6">
<div className="grid gap-4">
<div>
<Label htmlFor="workflow_name">Workflow Name *</Label>
<Input
id="workflow_name"
value={workflowData.workflow_name}
onChange={(e) =>
setWorkflowData({
...workflowData,
workflow_name: e.target.value,
})
}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-6">
<Card className="p-6">
<div className="grid gap-4">
<div>
<Label htmlFor="name">Workflow Name *</Label>
<Input
id="name"
value={workflowData.workflow_name}
onChange={(e) =>
setWorkflowData({
...workflowData,
workflow_name: e.target.value,
})
}
placeholder="e.g. Standard RFA Workflow"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={workflowData.description}
onChange={(e) =>
setWorkflowData({
...workflowData,
description: e.target.value,
})
}
/>
</div>
<div>
<Label htmlFor="desc">Description</Label>
<Textarea
id="desc"
value={workflowData.description}
onChange={(e) =>
setWorkflowData({
...workflowData,
description: e.target.value,
})
}
placeholder="Describe the purpose of this workflow"
/>
</div>
<div>
<Label htmlFor="workflow_type">Workflow Type</Label>
<Select
value={workflowData.workflow_type}
onValueChange={(value) =>
setWorkflowData({ ...workflowData, workflow_type: value as WorkflowType })
}
>
<SelectTrigger id="workflow_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CORRESPONDENCE">Correspondence</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="DRAWING">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="type">Workflow Type</Label>
<Select
value={workflowData.workflow_type}
onValueChange={(value: Workflow['workflow_type']) =>
setWorkflowData({ ...workflowData, workflow_type: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CORRESPONDENCE">Correspondence</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="DRAWING">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
</div>
</Card>
<Tabs defaultValue="dsl">
<TabsList>
<TabsTrigger value="dsl">DSL Editor</TabsTrigger>
<TabsTrigger value="visual">Visual Builder</TabsTrigger>
</TabsList>
<div className="lg:col-span-2">
<Tabs defaultValue="dsl" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="dsl">DSL Editor</TabsTrigger>
<TabsTrigger value="visual">Visual Builder</TabsTrigger>
</TabsList>
<TabsContent value="dsl" className="mt-4">
<DSLEditor
initialValue={workflowData.dsl_definition}
onChange={(value) =>
setWorkflowData({ ...workflowData, dsl_definition: value })
}
/>
</TabsContent>
<TabsContent value="dsl" className="mt-4">
<DSLEditor
initialValue={workflowData.dsl_definition}
onChange={(value) =>
setWorkflowData({ ...workflowData, dsl_definition: value })
}
/>
</TabsContent>
<TabsContent value="visual" className="mt-4">
<VisualWorkflowBuilder />
</TabsContent>
</Tabs>
<TabsContent value="visual" className="mt-4 h-[600px]">
<VisualWorkflowBuilder
dslString={workflowData.dsl_definition}
onDslChange={(newDsl) => setWorkflowData({ ...workflowData, dsl_definition: newDsl })}
onSave={() => toast.info("Visual state saving not implemented in this demo")}
/>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { AdminSidebar } from "@/components/admin/sidebar";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { auth } from "@/lib/auth";
export default async function AdminLayout({
@@ -9,16 +8,13 @@ export default async function AdminLayout({
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const session = await auth();
// Check if user has admin role
// This depends on your Session structure. Assuming user.roles exists (mapped in callback).
// If not, you might need to check DB or use Can component logic but server-side.
const isAdmin = session?.user?.roles?.some((r: any) => r.role_name === 'ADMIN');
// Temporary bypass for UI testing
const isAdmin = true; // session?.user?.role === 'ADMIN';
if (!session || !isAdmin) {
// If not admin, redirect to dashboard or login
redirect("/");
// redirect("/");
}
return (

View File

@@ -1,16 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { SearchFilters } from "@/components/search/filters";
import { SearchResults } from "@/components/search/results";
import { SearchFilters as FilterType } from "@/types/search";
import { useSearch } from "@/hooks/use-search";
import { Button } from "@/components/ui/button";
export default function SearchPage() {
const searchParams = useSearchParams();
const router = useRouter();
// URL Params state
const query = searchParams.get("q") || "";
@@ -65,7 +63,7 @@ export default function SearchPage() {
{isError ? (
<div className="text-red-500 py-8 text-center">Failed to load search results.</div>
) : (
<SearchResults results={results || []} query={query} loading={isLoading} />
<SearchResults results={results?.data || []} query={query} loading={isLoading} />
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Users, Building2, Settings, FileText, Activity } from "lucide-react";
import { Users, Building2, Settings, FileText, Activity, GitGraph } from "lucide-react";
const menuItems = [
{ href: "/admin/users", label: "Users", icon: Users },
@@ -11,6 +11,8 @@ const menuItems = [
{ href: "/admin/projects", label: "Projects", icon: FileText },
{ href: "/admin/settings", label: "Settings", icon: Settings },
{ href: "/admin/audit-logs", label: "Audit Logs", icon: Activity },
{ href: "/admin/workflows", label: "Workflows", icon: GitGraph },
{ href: "/admin/numbering", label: "Numbering", icon: FileText },
];
export function AdminSidebar() {

View File

@@ -1,6 +1,6 @@
'use client';
import { useSession } from 'next-auth/react';
import { useSession, signOut } from 'next-auth/react';
import { useEffect } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
@@ -9,7 +9,9 @@ export function AuthSync() {
const { setAuth, logout } = useAuthStore();
useEffect(() => {
if (status === 'authenticated' && session?.user) {
if (session?.error === 'RefreshAccessTokenError') {
signOut({ callbackUrl: '/login' });
} else if (status === 'authenticated' && session?.user) {
// Map NextAuth session to AuthStore user
// Assuming session.user has the fields we need based on types/next-auth.d.ts

View File

@@ -1,19 +1,55 @@
"use client";
import { Correspondence } from "@/types/correspondence";
import { Correspondence, Attachment } from "@/types/correspondence";
import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { format } from "date-fns";
import { ArrowLeft, Download, FileText } from "lucide-react";
import { ArrowLeft, Download, FileText, Loader2, Send, CheckCircle, XCircle } from "lucide-react";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { useSubmitCorrespondence, useProcessWorkflow } from "@/hooks/use-correspondence";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
interface CorrespondenceDetailProps {
data: Correspondence;
}
export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
const submitMutation = useSubmitCorrespondence();
const processMutation = useProcessWorkflow();
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
const [comments, setComments] = useState("");
const handleSubmit = () => {
if (confirm("Are you sure you want to submit this correspondence?")) {
// TODO: Implement Template Selection. Hardcoded to 1 for now.
submitMutation.mutate({
id: data.correspondence_id,
data: { templateId: 1 }
});
}
};
const handleProcess = () => {
if (!actionState) return;
const action = actionState === "approve" ? "APPROVE" : "REJECT";
processMutation.mutate({
id: data.correspondence_id,
data: {
action,
comments
}
}, {
onSuccess: () => {
setActionState(null);
setComments("");
}
});
};
return (
<div className="space-y-6">
{/* Header / Actions */}
@@ -32,19 +68,66 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
</div>
</div>
<div className="flex gap-2">
{/* Workflow Actions Placeholder */}
{data.status === "DRAFT" && (
<Button>Submit for Review</Button>
<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" />}
Submit for Review
</Button>
)}
{data.status === "IN_REVIEW" && (
<>
<Button variant="destructive">Reject</Button>
<Button className="bg-green-600 hover:bg-green-700">Approve</Button>
<Button
variant="destructive"
onClick={() => setActionState("reject")}
>
<XCircle className="mr-2 h-4 w-4" />
Reject
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => setActionState("approve")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</Button>
</>
)}
</div>
</div>
{/* Action Input Area */}
{actionState && (
<Card className="border-primary">
<CardHeader>
<CardTitle className="text-lg">
{actionState === "approve" ? "Confirm Approval" : "Confirm Rejection"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Comments</Label>
<Textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Enter comments..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setActionState(null)}>Cancel</Button>
<Button
variant={actionState === "approve" ? "default" : "destructive"}
onClick={handleProcess}
disabled={processMutation.isPending}
className={actionState === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
>
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm {actionState === "approve" ? "Approve" : "Reject"}
</Button>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
@@ -63,23 +146,25 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
</p>
</div>
<Separator />
<hr className="my-4 border-t" />
<div>
<h3 className="font-semibold mb-3">Attachments</h3>
{data.attachments && data.attachments.length > 0 ? (
<div className="grid gap-2">
{data.attachments.map((file: any, index: number) => (
{data.attachments.map((file, index) => (
<div
key={index}
key={file.id || index}
className="flex items-center justify-between p-3 border rounded-lg bg-muted/20"
>
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-primary" />
<span className="text-sm font-medium">{file.name || `Attachment ${index + 1}`}</span>
<span className="text-sm font-medium">{file.name}</span>
</div>
<Button variant="ghost" size="sm">
<Download className="h-4 w-4" />
<Button variant="ghost" size="sm" asChild>
<a href={file.url} target="_blank" rel="noopener noreferrer">
<Download className="h-4 w-4" />
</a>
</Button>
</div>
))}
@@ -111,7 +196,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
</div>
</div>
<Separator />
<hr className="my-4 border-t" />
<div>
<p className="text-sm font-medium text-muted-foreground">From Organization</p>

View File

@@ -19,11 +19,12 @@ import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useCreateCorrespondence } from "@/hooks/use-correspondence";
import { useOrganizations } from "@/hooks/use-master-data";
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
const correspondenceSchema = z.object({
subject: z.string().min(5, "Subject must be at least 5 characters"),
description: z.string().optional(),
document_type_id: z.number().default(1), // Default to General for now
document_type_id: z.number().default(1),
from_organization_id: z.number({ required_error: "Please select From Organization" }),
to_organization_id: z.number({ required_error: "Please select To Organization" }),
importance: z.enum(["NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
@@ -41,18 +42,38 @@ export function CorrespondenceForm() {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(correspondenceSchema),
defaultValues: {
importance: "NORMAL",
document_type_id: 1,
// @ts-ignore: Intentionally undefined for required fields to force selection
from_organization_id: undefined,
to_organization_id: undefined,
},
});
const onSubmit = (data: FormData) => {
createMutation.mutate(data as any, {
// Map FormData to CreateCorrespondenceDto
// Note: projectId is hardcoded to 1 for now as per requirements/context
const payload: CreateCorrespondenceDto = {
projectId: 1,
typeId: data.document_type_id,
title: data.subject,
description: data.description,
originatorId: data.from_organization_id, // Mapping From -> Originator (Impersonation)
details: {
to_organization_id: data.to_organization_id,
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:
createMutation.mutate(payload, {
onSuccess: () => {
router.push("/correspondences");
},
@@ -61,7 +82,6 @@ export function CorrespondenceForm() {
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">Subject *</Label>
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
@@ -70,7 +90,6 @@ export function CorrespondenceForm() {
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
@@ -81,7 +100,6 @@ export function CorrespondenceForm() {
/>
</div>
{/* From/To Organizations */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>From Organization *</Label>
@@ -93,9 +111,9 @@ export function CorrespondenceForm() {
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger>
<SelectContent>
{organizations?.map((org: any) => (
<SelectItem key={org.id} value={String(org.id)}>
{org.name || org.org_name} ({org.code || org.org_code})
{organizations?.map((org) => (
<SelectItem key={org.organization_id} value={String(org.organization_id)}>
{org.org_name} ({org.org_code})
</SelectItem>
))}
</SelectContent>
@@ -115,9 +133,9 @@ export function CorrespondenceForm() {
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger>
<SelectContent>
{organizations?.map((org: any) => (
<SelectItem key={org.id} value={String(org.id)}>
{org.name || org.org_name} ({org.code || org.org_code})
{organizations?.map((org) => (
<SelectItem key={org.organization_id} value={String(org.organization_id)}>
{org.org_name} ({org.org_code})
</SelectItem>
))}
</SelectContent>
@@ -128,7 +146,6 @@ export function CorrespondenceForm() {
</div>
</div>
{/* Importance */}
<div className="space-y-2">
<Label>Importance</Label>
<div className="flex gap-6 mt-2">
@@ -162,7 +179,6 @@ export function CorrespondenceForm() {
</div>
</div>
{/* File Attachments */}
<div className="space-y-2">
<Label>Attachments</Label>
<FileUpload
@@ -172,7 +188,6 @@ export function CorrespondenceForm() {
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-4 pt-6 border-t">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel

View File

@@ -2,6 +2,7 @@
import { DrawingCard } from "@/components/drawings/card";
import { useDrawings } from "@/hooks/use-drawing";
import { Drawing } from "@/types/drawing";
import { Loader2 } from "lucide-react";
interface DrawingListProps {
@@ -9,7 +10,7 @@ interface DrawingListProps {
}
export function DrawingList({ type }: DrawingListProps) {
const { data: drawings, isLoading, isError } = useDrawings(type, { type });
const { data: drawings, isLoading, isError } = useDrawings(type, { projectId: 1 });
// Note: The hook handles switching services based on type.
// The params { type } might be redundant if getAll doesn't use it, but safe to pass.
@@ -30,7 +31,7 @@ export function DrawingList({ type }: DrawingListProps) {
);
}
if (!drawings || drawings.length === 0) {
if (!drawings?.data || drawings.data.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
No drawings found.
@@ -40,8 +41,8 @@ export function DrawingList({ type }: DrawingListProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{drawings.map((drawing: any) => (
<DrawingCard key={drawing[type === 'CONTRACT' ? 'contract_drawing_id' : 'shop_drawing_id'] || drawing.id || drawing.drawing_id} drawing={drawing} />
{drawings.data.map((drawing: Drawing) => (
<DrawingCard key={(drawing as any)[type === 'CONTRACT' ? 'contract_drawing_id' : 'shop_drawing_id'] || drawing.drawing_id || (drawing as any).id} drawing={drawing} />
))}
</div>
);

View File

@@ -60,7 +60,7 @@ export function Sidebar({ className }: SidebarProps) {
title: "Admin Panel",
href: "/admin",
icon: Shield,
permission: "admin", // Only admins
permission: null, // "admin", // Temporarily visible for all
},
];

View File

@@ -1,43 +1,44 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RefreshCw, Loader2 } from "lucide-react";
import { numberingApi } from "@/lib/api/numbering";
import { NumberingSequence } from "@/types/numbering";
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { RefreshCw } from 'lucide-react';
import { numberingApi, NumberSequence } from '@/lib/api/numbering';
export function SequenceViewer({ templateId }: { templateId: number }) {
const [sequences, setSequences] = useState<NumberingSequence[]>([]);
export function SequenceViewer() {
const [sequences, setSequences] = useState<NumberSequence[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const fetchSequences = async () => {
setLoading(true);
try {
const data = await numberingApi.getSequences(templateId);
setSequences(data);
} catch (error) {
console.error("Failed to fetch sequences", error);
const data = await numberingApi.getSequences();
setSequences(data);
} finally {
setLoading(false);
setLoading(false);
}
};
useEffect(() => {
if (templateId) {
fetchSequences();
}
}, [templateId]);
fetchSequences();
}, []);
const filteredSequences = sequences.filter(s =>
s.year.toString().includes(search) ||
s.organization_code?.toLowerCase().includes(search.toLowerCase()) ||
s.discipline_code?.toLowerCase().includes(search.toLowerCase())
);
return (
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Number Sequences</h3>
<Button variant="outline" size="sm" onClick={fetchSequences} disabled={loading}>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
@@ -50,43 +51,36 @@ export function SequenceViewer({ templateId }: { templateId: number }) {
/>
</div>
{loading && sequences.length === 0 ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-2">
{sequences.length === 0 ? (
<p className="text-center text-muted-foreground py-4">No sequences found.</p>
) : (
sequences.map((seq) => (
<div
key={seq.sequence_id}
className="flex items-center justify-between p-3 bg-muted/50 rounded"
>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{seq.year}</span>
{seq.organization_code && (
<Badge>{seq.organization_code}</Badge>
)}
{seq.discipline_code && (
<Badge variant="outline">{seq.discipline_code}</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
Current: {seq.current_number} | Last Generated:{" "}
{seq.last_generated_number}
</div>
</div>
<div className="text-sm text-muted-foreground">
Updated {new Date(seq.updated_at).toLocaleDateString()}
</div>
<div className="space-y-2">
{filteredSequences.length === 0 && (
<div className="text-center text-muted-foreground py-4">No sequences found</div>
)}
{filteredSequences.map((seq) => (
<div
key={seq.sequence_id}
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded border"
>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">Year {seq.year}</span>
{seq.organization_code && (
<Badge>{seq.organization_code}</Badge>
)}
{seq.discipline_code && (
<Badge variant="outline">{seq.discipline_code}</Badge>
)}
</div>
))
)}
</div>
)}
<div className="text-sm text-muted-foreground">
<span className="text-foreground font-medium">Current: {seq.current_number}</span> | Last Generated:{' '}
<span className="font-mono">{seq.last_generated_number}</span>
</div>
</div>
<div className="text-sm text-gray-500">
Updated {new Date(seq.updated_at).toLocaleDateString()}
</div>
</div>
))}
</div>
</Card>
);
}

View File

@@ -1,99 +1,133 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { CreateTemplateDto } from "@/types/numbering";
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { NumberingTemplate } from '@/lib/api/numbering';
const VARIABLES = [
{ key: "{ORG}", name: "Organization Code", example: "กทท" },
{ key: "{DOCTYPE}", name: "Document Type", example: "CORR" },
{ key: "{DISC}", name: "Discipline", example: "STR" },
{ key: "{YYYY}", name: "Year (4-digit)", example: "2025" },
{ key: "{YY}", name: "Year (2-digit)", example: "25" },
{ key: "{MM}", name: "Month", example: "12" },
{ key: "{SEQ}", name: "Sequence Number", example: "0001" },
{ key: "{CONTRACT}", name: "Contract Code", example: "C01" },
const DOCUMENT_TYPES = [
{ value: 'RFA', label: 'Request for Approval (RFA)' },
{ value: 'RFI', label: 'Request for Information (RFI)' },
{ value: 'TRANSMITTAL', label: 'Transmittal' },
{ value: 'EMAIL', label: 'Email' },
{ value: 'INSTRUCTION', label: 'Instruction' },
{ value: 'LETTER', label: 'Letter' },
{ value: 'MEMO', label: 'Memorandum' },
{ value: 'MOM', label: 'Minutes of Meeting' },
{ value: 'NOTICE', label: 'Notice' },
{ value: 'OTHER', label: 'Other' },
];
interface TemplateEditorProps {
initialData?: Partial<CreateTemplateDto>;
onSave: (data: CreateTemplateDto) => void;
const VARIABLES = [
{ key: '{PROJECT}', name: 'Project Code', example: 'LCBP3' },
{ key: '{ORIGINATOR}', name: 'Originator Code', example: 'PAT' },
{ key: '{RECIPIENT}', name: 'Recipient Code', example: 'CN' },
{ key: '{CORR_TYPE}', name: 'Correspondence Type', example: 'RFA' },
{ key: '{SUB_TYPE}', name: 'Sub Type', example: '21' },
{ key: '{DISCIPLINE}', name: 'Discipline', example: 'STR' },
{ key: '{RFA_TYPE}', name: 'RFA Type', example: 'SDW' },
{ key: '{YEAR:B.E.}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR:A.D.}', name: 'Year (A.D.)', example: '2025' },
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
{ key: '{REV}', name: 'Revision', example: 'A' },
];
export interface TemplateEditorProps {
template?: NumberingTemplate;
projectId: number;
projectName: string;
onSave: (data: Partial<NumberingTemplate>) => void;
onCancel: () => void;
}
export function TemplateEditor({ initialData, onSave }: TemplateEditorProps) {
const [formData, setFormData] = useState<CreateTemplateDto>({
document_type_id: initialData?.document_type_id || "",
discipline_code: initialData?.discipline_code || "",
template_format: initialData?.template_format || "",
reset_annually: initialData?.reset_annually ?? true,
padding_length: initialData?.padding_length || 4,
starting_number: initialData?.starting_number || 1,
});
const [preview, setPreview] = useState("");
export function TemplateEditor({ template, projectId, projectName, onSave, onCancel }: TemplateEditorProps) {
const [format, setFormat] = useState(template?.template_format || '');
const [docType, setDocType] = useState(template?.document_type_name || '');
const [discipline, setDiscipline] = useState(template?.discipline_code || '');
const [padding, setPadding] = useState(template?.padding_length || 4);
const [reset, setReset] = useState(template?.reset_annually ?? true);
const [preview, setPreview] = useState('');
useEffect(() => {
// Generate preview
let previewText = formData.template_format;
let previewText = format || '';
VARIABLES.forEach((v) => {
// Escape special characters for regex if needed, but simple replaceAll is safer for fixed strings
previewText = previewText.split(v.key).join(v.example);
let replacement = v.example;
// Dynamic preview for dates to be more realistic
if (v.key === '{YYYY}') replacement = new Date().getFullYear().toString();
if (v.key === '{YY}') replacement = new Date().getFullYear().toString().slice(-2);
if (v.key === '{THXXXX}') replacement = (new Date().getFullYear() + 543).toString();
if (v.key === '{THXX}') replacement = (new Date().getFullYear() + 543).toString().slice(-2);
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
});
setPreview(previewText);
}, [formData.template_format]);
}, [format]);
const insertVariable = (variable: string) => {
setFormData((prev) => ({
...prev,
template_format: prev.template_format + variable,
}));
setFormat((prev) => prev + variable);
};
const handleSave = () => {
onSave({
...template,
project_id: projectId, // Ensure project_id is included
template_format: format,
document_type_name: docType,
discipline_code: discipline || undefined,
padding_length: padding,
reset_annually: reset,
example_number: preview
});
};
return (
<Card className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Template Configuration</h3>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
<Badge variant="outline" className="text-base px-3 py-1">
Project: {projectName}
</Badge>
</div>
<div className="grid gap-4">
<div>
<Label>Document Type *</Label>
<Select
value={formData.document_type_id}
onValueChange={(value) => setFormData({ ...formData, document_type_id: value })}
>
<Select value={docType} onValueChange={setDocType}>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="drawing">Drawing</SelectItem>
{DOCUMENT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select
value={formData.discipline_code}
onValueChange={(value) => setFormData({ ...formData, discipline_code: value })}
>
<Select value={discipline || "ALL"} onValueChange={(val) => setDiscipline(val === "ALL" ? "" : val)}>
<SelectTrigger>
<SelectValue placeholder="All disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value="STR">STR - Structure</SelectItem>
<SelectItem value="ARC">ARC - Architecture</SelectItem>
</SelectContent>
@@ -104,10 +138,10 @@ export function TemplateEditor({ initialData, onSave }: TemplateEditorProps) {
<Label>Template Format *</Label>
<div className="space-y-2">
<Input
value={formData.template_format}
onChange={(e) => setFormData({ ...formData, template_format: e.target.value })}
placeholder="e.g., {ORG}-{DOCTYPE}-{YYYY}-{SEQ}"
className="font-mono"
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="e.g., {ORIGINATOR}-{RECIPIENT}-{DOCTYPE}-{YYYY}-{SEQ}"
className="font-mono text-base"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
@@ -117,6 +151,7 @@ export function TemplateEditor({ initialData, onSave }: TemplateEditorProps) {
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
className="font-mono text-xs"
>
{v.key}
</Button>
@@ -128,9 +163,9 @@ export function TemplateEditor({ initialData, onSave }: TemplateEditorProps) {
<div>
<Label>Preview</Label>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-muted-foreground mb-1">Example number:</p>
<p className="text-sm text-gray-600 mb-1">Example number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{preview || "Enter format above"}
{preview || 'Enter format above'}
</p>
</div>
</div>
@@ -140,34 +175,20 @@ export function TemplateEditor({ initialData, onSave }: TemplateEditorProps) {
<Label>Sequence Padding Length</Label>
<Input
type="number"
value={formData.padding_length}
onChange={(e) => setFormData({ ...formData, padding_length: parseInt(e.target.value) })}
min={1}
max={10}
value={padding}
onChange={e => setPadding(Number(e.target.value))}
min={1} max={10}
/>
<p className="text-xs text-muted-foreground mt-1">
Number of digits (e.g., 4 = 0001, 0002)
Number of digits (e.g., 4 = 0001)
</p>
</div>
<div>
<Label>Starting Number</Label>
<Input
type="number"
value={formData.starting_number}
onChange={(e) => setFormData({ ...formData, starting_number: parseInt(e.target.value) })}
min={1}
/>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2">
<Checkbox
checked={formData.reset_annually}
onCheckedChange={(checked) => setFormData({ ...formData, reset_annually: checked as boolean })}
/>
<span className="text-sm">Reset annually (on January 1st)</span>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={reset} onCheckedChange={(checked) => setReset(!!checked)} />
<span className="text-sm select-none">Reset annually (on January 1st)</span>
</label>
</div>
</div>
@@ -176,27 +197,27 @@ export function TemplateEditor({ initialData, onSave }: TemplateEditorProps) {
{/* Variable Reference */}
<div>
<h4 className="font-semibold mb-3">Available Variables</h4>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{VARIABLES.map((v) => (
<div
key={v.key}
className="flex items-center justify-between p-2 bg-muted/50 rounded"
className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-900 rounded border"
>
<div>
<Badge variant="outline" className="font-mono">
<Badge variant="outline" className="font-mono bg-white dark:bg-black">
{v.key}
</Badge>
<p className="text-xs text-muted-foreground mt-1">{v.name}</p>
</div>
<span className="text-sm text-muted-foreground">{v.example}</span>
<span className="text-sm text-foreground">{v.example}</span>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => window.history.back()}>Cancel</Button>
<Button onClick={() => onSave(formData)}>Save Template</Button>
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={handleSave}>Save Template</Button>
</div>
</Card>
);

View File

@@ -1,52 +1,48 @@
"use client";
'use client';
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { NumberingTemplate } from "@/types/numbering";
import { numberingApi } from "@/lib/api/numbering";
import { Loader2 } from "lucide-react";
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
import { Loader2 } from 'lucide-react';
interface TemplateTesterProps {
open: boolean;
onOpenChange: (open: boolean) => void;
template: NumberingTemplate | null;
open: boolean;
onOpenChange: (open: boolean) => void;
template: NumberingTemplate | null;
}
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
const [testData, setTestData] = useState({
organization_id: "1",
discipline_id: "1",
organization_id: '1',
discipline_id: '1',
year: new Date().getFullYear(),
});
const [generatedNumber, setGeneratedNumber] = useState("");
const [generatedNumber, setGeneratedNumber] = useState('');
const [loading, setLoading] = useState(false);
const handleTest = async () => {
if (!template) return;
setLoading(true);
try {
const result = await numberingApi.testTemplate(template.template_id, testData);
setGeneratedNumber(result.number);
} catch (error) {
console.error("Failed to generate test number", error);
setGeneratedNumber("Error generating number");
const result = await numberingApi.generateTestNumber(template.template_id, testData);
setGeneratedNumber(result.number);
} finally {
setLoading(false);
setLoading(false);
}
};
@@ -57,35 +53,34 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
<DialogTitle>Test Number Generation</DialogTitle>
</DialogHeader>
<div className="text-sm text-muted-foreground mb-2">
Template: <span className="font-mono font-bold text-foreground">{template?.template_format}</span>
</div>
<div className="space-y-4">
<div>
<Label>Organization</Label>
<Select
value={testData.organization_id}
onValueChange={(value) => setTestData({ ...testData, organization_id: value })}
>
<Label>Organization (Mock Context)</Label>
<Select value={testData.organization_id} onValueChange={v => setTestData({...testData, organization_id: v})}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
<SelectItem value="1">Port Authority (PAT/)</SelectItem>
<SelectItem value="2">Contractor (CN/)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select
value={testData.discipline_id}
onValueChange={(value) => setTestData({ ...testData, discipline_id: value })}
>
<Label>Discipline (Mock Context)</Label>
<Select value={testData.discipline_id} onValueChange={v => setTestData({...testData, discipline_id: v})}>
<SelectTrigger>
<SelectValue placeholder="Select discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">STR</SelectItem>
<SelectItem value="2">ARC</SelectItem>
<SelectItem value="1">Structure (STR)</SelectItem>
<SelectItem value="2">Architecture (ARC)</SelectItem>
<SelectItem value="3">General (GEN)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -96,9 +91,9 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</Button>
{generatedNumber && (
<Card className="p-4 bg-green-50 border-green-200">
<Card className="p-4 bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900 border text-center">
<p className="text-sm text-muted-foreground mb-1">Generated Number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
<p className="text-2xl font-mono font-bold text-green-700 dark:text-green-400">
{generatedNumber}
</p>
</Card>

View File

@@ -7,18 +7,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { format } from "date-fns";
import { ArrowLeft, CheckCircle, XCircle, Loader2 } from "lucide-react";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { rfaApi } from "@/lib/api/rfas"; // Deprecated, remove if possible
import { useRouter } from "next/navigation";
import { useProcessRFA } from "@/hooks/use-rfa";
@@ -28,12 +19,14 @@ interface RFADetailProps {
export function RFADetail({ data }: RFADetailProps) {
const router = useRouter();
const [approvalDialog, setApprovalDialog] = useState<"approve" | "reject" | null>(null);
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
const [comments, setComments] = useState("");
const processMutation = useProcessRFA();
const handleApproval = async (action: "approve" | "reject") => {
const apiAction = action === "approve" ? "APPROVE" : "REJECT";
const handleProcess = () => {
if (!actionState) return;
const apiAction = actionState === "approve" ? "APPROVE" : "REJECT";
processMutation.mutate(
{
@@ -45,7 +38,8 @@ export function RFADetail({ data }: RFADetailProps) {
},
{
onSuccess: () => {
setApprovalDialog(null);
setActionState(null);
setComments("");
// Query invalidation handled in hook
},
}
@@ -75,14 +69,14 @@ export function RFADetail({ data }: RFADetailProps) {
<Button
variant="outline"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setApprovalDialog("reject")}
onClick={() => setActionState("reject")}
>
<XCircle className="mr-2 h-4 w-4" />
Reject
</Button>
<Button
className="bg-green-600 hover:bg-green-700 text-white"
onClick={() => setApprovalDialog("approve")}
onClick={() => setActionState("approve")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
@@ -91,6 +85,39 @@ export function RFADetail({ data }: RFADetailProps) {
)}
</div>
{/* Action Input Area */}
{actionState && (
<Card className="border-primary">
<CardHeader>
<CardTitle className="text-lg">
{actionState === "approve" ? "Confirm Approval" : "Confirm Rejection"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Comments</Label>
<Textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Enter comments..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setActionState(null)}>Cancel</Button>
<Button
variant={actionState === "approve" ? "default" : "destructive"}
onClick={handleProcess}
disabled={processMutation.isPending}
className={actionState === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
>
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm {actionState === "approve" ? "Approve" : "Reject"}
</Button>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
@@ -109,7 +136,7 @@ export function RFADetail({ data }: RFADetailProps) {
</p>
</div>
<Separator />
<hr className="my-4 border-t" />
<div>
<h3 className="font-semibold mb-3">RFA Items</h3>
@@ -156,7 +183,7 @@ export function RFADetail({ data }: RFADetailProps) {
<p className="font-medium mt-1">{data.contract_name}</p>
</div>
<Separator />
<hr className="my-4 border-t" />
<div>
<p className="text-sm font-medium text-muted-foreground">Discipline</p>
@@ -166,42 +193,6 @@ export function RFADetail({ data }: RFADetailProps) {
</Card>
</div>
</div>
{/* Approval Dialog */}
<Dialog open={!!approvalDialog} onOpenChange={(open) => !open && setApprovalDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{approvalDialog === "approve" ? "Approve RFA" : "Reject RFA"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Comments</Label>
<Textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Enter your comments here..."
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setApprovalDialog(null)} disabled={processMutation.isPending}>
Cancel
</Button>
<Button
variant={approvalDialog === "approve" ? "default" : "destructive"}
onClick={() => handleApproval(approvalDialog!)}
disabled={processMutation.isPending}
className={approvalDialog === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
>
{processMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{approvalDialog === "approve" ? "Confirm Approval" : "Confirm Rejection"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -17,8 +17,8 @@ import {
} from "@/components/ui/select";
import { useRouter } from "next/navigation";
import { useCreateRFA } from "@/hooks/use-rfa";
import { useDisciplines } from "@/hooks/use-master-data";
import { useState } from "react";
import { useDisciplines, useContracts } from "@/hooks/use-master-data";
import { CreateRFADto } from "@/types/rfa";
const rfaItemSchema = z.object({
item_no: z.string().min(1, "Item No is required"),
@@ -41,31 +41,36 @@ export function RFAForm() {
const router = useRouter();
const createMutation = useCreateRFA();
// Fetch Disciplines (Assuming Contract 1 for now, or dynamic)
const selectedContractId = 1;
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
// Dynamic Contract Loading (Default Project Context: 1)
const currentProjectId = 1;
const { data: contracts, isLoading: isLoadingContracts } = useContracts(currentProjectId);
const {
register,
control,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<RFAFormData>({
resolver: zodResolver(rfaSchema),
defaultValues: {
contract_id: 1,
contract_id: undefined, // Force selection
items: [{ item_no: "1", description: "", quantity: 0, unit: "" }],
},
});
const selectedContractId = watch("contract_id");
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
const onSubmit = (data: RFAFormData) => {
createMutation.mutate(data as any, {
// Map to DTO if needed, assuming generic structure matches
createMutation.mutate(data as unknown as CreateRFADto, {
onSuccess: () => {
router.push("/rfas");
},
@@ -99,14 +104,17 @@ export function RFAForm() {
<Label>Contract *</Label>
<Select
onValueChange={(v) => setValue("contract_id", parseInt(v))}
defaultValue="1"
disabled={isLoadingContracts}
>
<SelectTrigger>
<SelectValue placeholder="Select Contract" />
<SelectValue placeholder={isLoadingContracts ? "Loading..." : "Select Contract"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Main Construction Contract</SelectItem>
{/* Additional contracts can be fetched via API too */}
{contracts?.map((c: any) => (
<SelectItem key={c.id || c.contract_id} value={String(c.id || c.contract_id)}>
{c.name || c.contract_no}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.contract_id && (
@@ -118,7 +126,7 @@ export function RFAForm() {
<Label>Discipline *</Label>
<Select
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
disabled={isLoadingDisciplines}
disabled={!selectedContractId || isLoadingDisciplines}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} />

View File

@@ -19,6 +19,8 @@ interface RFAListProps {
}
export function RFAList({ data }: RFAListProps) {
if (!data) return null;
const columns: ColumnDef<RFA>[] = [
{
accessorKey: "rfa_number",
@@ -73,7 +75,7 @@ export function RFAList({ data }: RFAListProps) {
return (
<div>
<DataTable columns={columns} data={data.items} />
<DataTable columns={columns} data={data?.items || []} />
{/* Pagination component would go here */}
</div>
);

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -1,29 +1,45 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { CheckCircle, AlertCircle, Play, Loader2 } from "lucide-react";
import Editor from "@monaco-editor/react";
import { workflowApi } from "@/lib/api/workflows";
import { ValidationResult } from "@/types/workflow";
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle, AlertCircle, Play, Loader2 } from 'lucide-react';
import Editor, { OnMount } from '@monaco-editor/react';
import { workflowApi } from '@/lib/api/workflows';
import { ValidationResult } from '@/types/workflow';
import { useTheme } from 'next-themes';
interface DSLEditorProps {
initialValue?: string;
onChange?: (value: string) => void;
readOnly?: boolean;
}
export function DSLEditor({ initialValue = "", onChange }: DSLEditorProps) {
export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSLEditorProps) {
const [dsl, setDsl] = useState(initialValue);
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
const editorRef = useRef<unknown>(null);
const { theme } = useTheme();
// Update internal state if initialValue changes (e.g. loaded from API)
useEffect(() => {
setDsl(initialValue);
}, [initialValue]);
const handleEditorChange = (value: string | undefined) => {
const newValue = value || "";
const newValue = value || '';
setDsl(newValue);
onChange?.(newValue);
setValidationResult(null); // Clear validation on change
// Clear previous validation result on edit to avoid stale state
if (validationResult) {
setValidationResult(null);
}
};
const handleEditorDidMount: OnMount = (editor) => {
editorRef.current = editor;
};
const validateDSL = async () => {
@@ -32,15 +48,33 @@ export function DSLEditor({ initialValue = "", onChange }: DSLEditorProps) {
const result = await workflowApi.validateDSL(dsl);
setValidationResult(result);
} catch (error) {
console.error(error);
setValidationResult({ valid: false, errors: ["Validation failed due to an error"] });
console.error("Validation error:", error);
setValidationResult({ valid: false, errors: ['Validation failed due to server error'] });
} finally {
setIsValidating(false);
}
};
interface TestResult {
success: boolean;
message: string;
}
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [isTesting, setIsTesting] = useState(false);
const testWorkflow = async () => {
alert("Test workflow functionality to be implemented");
setIsTesting(true);
setTestResult(null);
try {
// Mock test execution
await new Promise(resolve => setTimeout(resolve, 1000));
setTestResult({ success: true, message: "Workflow simulation completed successfully." });
} catch {
setTestResult({ success: false, message: "Workflow simulation failed." });
} finally {
setIsTesting(false);
}
};
return (
@@ -51,50 +85,57 @@ export function DSLEditor({ initialValue = "", onChange }: DSLEditorProps) {
<Button
variant="outline"
onClick={validateDSL}
disabled={isValidating}
disabled={isValidating || readOnly}
>
{isValidating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle className="mr-2 h-4 w-4" />
<CheckCircle className="mr-2 h-4 w-4" />
)}
Validate
</Button>
<Button variant="outline" onClick={testWorkflow}>
<Play className="mr-2 h-4 w-4" />
<Button variant="outline" onClick={testWorkflow} disabled={isTesting || readOnly}>
{isTesting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
Test
</Button>
</div>
</div>
<Card className="overflow-hidden border rounded-md">
<Card className="overflow-hidden border-2">
<Editor
height="500px"
defaultLanguage="yaml"
value={dsl}
onChange={handleEditorChange}
theme="vs-dark"
onMount={handleEditorDidMount}
theme={theme === 'dark' ? 'vs-dark' : 'light'}
options={{
readOnly: readOnly,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
lineNumbers: 'on',
rulers: [80],
wordWrap: "on",
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
</Card>
{validationResult && (
<Alert variant={validationResult.valid ? "default" : "destructive"} className={validationResult.valid ? "border-green-500 text-green-700 bg-green-50" : ""}>
<Alert variant={validationResult.valid ? 'default' : 'destructive'} className={validationResult.valid ? "border-green-500 text-green-700 dark:text-green-400" : ""}>
{validationResult.valid ? (
<CheckCircle className="h-4 w-4 text-green-600" />
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>
{validationResult.valid ? (
"DSL is valid ✓"
<span className="font-semibold">DSL is valid and ready to deploy.</span>
) : (
<div>
<p className="font-medium mb-2">Validation Errors:</p>
@@ -110,6 +151,15 @@ export function DSLEditor({ initialValue = "", onChange }: DSLEditorProps) {
</AlertDescription>
</Alert>
)}
{testResult && (
<Alert variant={testResult.success ? 'default' : 'destructive'} className={testResult.success ? "border-blue-500 text-blue-700 dark:text-blue-400" : ""}>
{testResult.success ? <CheckCircle className="h-4 w-4"/> : <AlertCircle className="h-4 w-4"/>}
<AlertDescription>
{testResult.message}
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { useCallback } from "react";
import { useCallback, useEffect } from 'react';
import ReactFlow, {
Node,
Edge,
@@ -10,100 +10,275 @@ import ReactFlow, {
useEdgesState,
addEdge,
Connection,
} from "reactflow";
import "reactflow/dist/style.css";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
ReactFlowProvider,
Panel,
MarkerType,
useReactFlow,
} from 'reactflow';
import 'reactflow/dist/style.css';
const nodeTypes = {
// We can define custom node types here if needed
import { Button } from '@/components/ui/button';
import { Plus, Download, Save, Layout } from 'lucide-react';
// Define custom node styles (simplified for now)
const nodeStyle = {
padding: '10px 20px',
borderRadius: '8px',
border: '1px solid #ddd',
fontSize: '14px',
fontWeight: 500,
background: 'white',
color: '#333',
width: 180, // Increased width for role display
textAlign: 'center' as const,
whiteSpace: 'pre-wrap' as const, // Allow multiline
};
// Color mapping for node types
const nodeColors: Record<string, string> = {
start: "#10b981", // green
step: "#3b82f6", // blue
condition: "#f59e0b", // amber
end: "#ef4444", // red
const conditionNodeStyle = {
...nodeStyle,
background: '#fef3c7', // Amber-100
borderColor: '#d97706', // Amber-600
borderStyle: 'dashed',
borderRadius: '24px', // More rounded
};
export function VisualWorkflowBuilder() {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const initialNodes: Node[] = [
{
id: '1',
type: 'input',
data: { label: 'Start' },
position: { x: 250, y: 5 },
style: { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' },
},
];
interface VisualWorkflowBuilderProps {
initialNodes?: Node[];
initialEdges?: Edge[];
dslString?: string; // New prop
onSave?: (nodes: Node[], edges: Edge[]) => void;
onDslChange?: (dsl: string) => void;
}
function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
const nodes: Node[] = [];
const edges: Edge[] = [];
let yOffset = 100;
try {
// Simple line-based parser for the demo YAML structure
// name: Workflow
// steps:
// - name: Step1 ...
const lines = dsl.split('\n');
let currentStep: Record<string, string> | null = null;
const steps: Record<string, string>[] = [];
// Very basic parser logic (replace with js-yaml in production)
let inSteps = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('steps:')) {
inSteps = true;
continue;
}
if (inSteps && trimmed.startsWith('- name:')) {
if (currentStep) steps.push(currentStep);
currentStep = { name: trimmed.replace('- name:', '').trim() };
} else if (inSteps && currentStep && trimmed.startsWith('next:')) {
currentStep.next = trimmed.replace('next:', '').trim();
} else if (inSteps && currentStep && trimmed.startsWith('type:')) {
currentStep.type = trimmed.replace('type:', '').trim();
} else if (inSteps && currentStep && trimmed.startsWith('role:')) {
currentStep.role = trimmed.replace('role:', '').trim();
}
}
if (currentStep) steps.push(currentStep);
// Generate Nodes
nodes.push({
id: 'start',
type: 'input',
data: { label: 'Start' },
position: { x: 250, y: 0 },
style: { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' }
});
steps.forEach((step) => {
const isCondition = step.type === 'CONDITION';
nodes.push({
id: step.name,
data: { label: `${step.name}\n(${step.role || 'No Role'})`, name: step.name, role: step.role, type: step.type }, // Store role in data
position: { x: 250, y: yOffset },
style: isCondition ? conditionNodeStyle : { ...nodeStyle }
});
yOffset += 100;
});
nodes.push({
id: 'end',
type: 'output',
data: { label: 'End' },
position: { x: 250, y: yOffset },
style: { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' }
});
// Generate Edges
edges.push({ id: 'e-start-first', source: 'start', target: steps[0]?.name || 'end', markerEnd: { type: MarkerType.ArrowClosed } });
steps.forEach((step, index) => {
const nextStep = step.next || (index + 1 < steps.length ? steps[index + 1].name : 'end');
edges.push({
id: `e-${step.name}-${nextStep}`,
source: step.name,
target: nextStep,
markerEnd: { type: MarkerType.ArrowClosed }
});
});
} catch (e) {
console.error("Failed to parse DSL", e);
}
return { nodes, edges };
}
function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: propEdges, dslString, onSave, onDslChange }: VisualWorkflowBuilderProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(propNodes || initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(propEdges || []);
const { fitView } = useReactFlow();
// Sync DSL to nodes when dslString changes
useEffect(() => {
if (dslString) {
const { nodes: newNodes, edges: newEdges } = parseDSL(dslString);
if (newNodes.length > 0) {
setNodes(newNodes);
setEdges(newEdges);
// Fit view after update
setTimeout(() => fitView(), 100);
}
}
}, [dslString, setNodes, setEdges, fitView]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
[setEdges]
);
const addNode = (type: string) => {
const addNode = (type: string, label: string) => {
const id = `${type}-${Date.now()}`;
const newNode: Node = {
id: `${type}-${Date.now()}`,
type: "default", // Using default node type for now
position: { x: Math.random() * 400, y: Math.random() * 400 },
data: { label: `${type.charAt(0).toUpperCase() + type.slice(1)} Node` },
style: {
background: nodeColors[type] || "#64748b",
color: "white",
padding: 10,
borderRadius: 5,
border: "1px solid #fff",
width: 150,
},
id,
position: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
data: { label: label, name: label, role: 'User', type: type === 'condition' ? 'CONDITION' : 'APPROVAL' },
style: { ...nodeStyle },
};
setNodes((nds) => [...nds, newNode]);
if (type === 'end') {
newNode.style = { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' };
newNode.type = 'output';
} else if (type === 'start') {
newNode.style = { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' };
newNode.type = 'input';
} else if (type === 'condition') {
newNode.style = conditionNodeStyle;
}
setNodes((nds) => nds.concat(newNode));
};
const handleSave = () => {
onSave?.(nodes, edges);
};
// Mock DSL generation for demonstration
const generateDSL = () => {
// Convert visual workflow to DSL (Mock implementation)
const dsl = {
name: "Generated Workflow",
steps: nodes.map((node) => ({
step_name: node.data.label,
step_type: "APPROVAL",
})),
connections: edges.map((edge) => ({
from: edge.source,
to: edge.target,
})),
};
alert(JSON.stringify(dsl, null, 2));
const steps = nodes
.filter(n => n.type !== 'input' && n.type !== 'output')
.map(n => ({
// name: n.data.label, // Removed duplicate
// Actually, we should probably separate name and label display.
// For now, let's assume data.label IS the name, and we render it differently?
// Wait, ReactFlow Default Node renders 'label'.
// If I change label to "Name\nRole", then generateDSL will use "Name\nRole" as name.
// BAD.
// Fix: ReactFlow Node Component.
// custom Node?
// Quick fix: Keep label as Name. Render a CUSTOM NODE?
// Or just parsing: keep label as name.
// But user wants to SEE role.
// If I change label, I break name.
// Change: Use data.name for name, data.role for role.
// And label = `${name}\n(${role})`
// And here: use data.name if available, else label (cleaned).
name: n.data.name || n.data.label.split('\n')[0],
role: n.data.role,
type: n.data.type || 'APPROVAL', // Use stored type
next: edges.find(e => e.source === n.id)?.target || 'End'
}));
const dsl = `name: Visual Workflow
steps:
${steps.map(s => ` - name: ${s.name}
role: ${s.role || 'User'}
type: ${s.type}
next: ${s.next}`).join('\n')}`;
console.log("Generated DSL:", dsl);
onDslChange?.(dsl);
alert("DSL Updated from Visual Builder!");
};
return (
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
<Button onClick={() => addNode("start")} variant="outline" size="sm" className="border-green-500 text-green-600 hover:bg-green-50">
Add Start
</Button>
<Button onClick={() => addNode("step")} variant="outline" size="sm" className="border-blue-500 text-blue-600 hover:bg-blue-50">
Add Step
</Button>
<Button onClick={() => addNode("condition")} variant="outline" size="sm" className="border-amber-500 text-amber-600 hover:bg-amber-50">
Add Condition
</Button>
<Button onClick={() => addNode("end")} variant="outline" size="sm" className="border-red-500 text-red-600 hover:bg-red-50">
Add End
</Button>
<Button onClick={generateDSL} className="ml-auto" size="sm">
Generate DSL
</Button>
</div>
<Card className="h-[600px] border">
<div className="space-y-4 h-full flex flex-col">
<div className="h-[600px] border rounded-lg overflow-hidden relative bg-slate-50 dark:bg-slate-950">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-right"
>
<Controls />
<Background color="#aaa" gap={16} />
<Panel position="top-right" className="flex gap-2 p-2 bg-white/80 dark:bg-black/50 rounded-lg backdrop-blur-sm border shadow-sm">
<Button size="sm" variant="secondary" onClick={() => addNode('step', 'New Step')}>
<Plus className="mr-2 h-4 w-4" /> Add Step
</Button>
<Button size="sm" variant="secondary" onClick={() => addNode('condition', 'Condition')}>
<Layout className="mr-2 h-4 w-4" /> Condition
</Button>
<Button size="sm" variant="secondary" onClick={() => addNode('end', 'End')}>
<Plus className="mr-2 h-4 w-4" /> Add End
</Button>
</Panel>
<Panel position="bottom-left" className="flex gap-2">
<Button size="sm" onClick={handleSave}>
<Save className="mr-2 h-4 w-4" /> Save Visual State
</Button>
<Button size="sm" variant="outline" onClick={generateDSL}>
<Download className="mr-2 h-4 w-4" /> Generate DSL
</Button>
</Panel>
</ReactFlow>
</Card>
</div>
<div className="text-sm text-muted-foreground">
<p>Tip: Drag to connect nodes. Use backspace to delete selected nodes.</p>
</div>
</div>
);
}
export function VisualWorkflowBuilder(props: VisualWorkflowBuilderProps) {
return (
<ReactFlowProvider>
<VisualWorkflowBuilderContent {...props} />
</ReactFlowProvider>
)
}

View File

@@ -70,4 +70,23 @@ export function useSubmitCorrespondence() {
});
}
export function useProcessWorkflow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number | string; data: any }) =>
correspondenceService.processWorkflow(id, data),
onSuccess: (_, { id }) => {
toast.success('Action completed successfully');
queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
},
onError: (error: any) => {
toast.error('Failed to process action', {
description: error.response?.data?.message || 'Something went wrong',
});
},
});
}
// Add more mutations as needed (update, delete, etc.)

View File

@@ -22,4 +22,12 @@ export function useDisciplines(contractId?: number) {
});
}
// Add other master data hooks as needed
// Add useContracts hook
import { projectService } from '@/lib/services/project.service';
export function useContracts(projectId: number = 1) {
return useQuery({
queryKey: ['contracts', projectId],
queryFn: () => projectService.getContracts(projectId),
});
}

View File

@@ -1,111 +1,159 @@
import { NumberingTemplate, NumberingSequence, CreateTemplateDto, TestGenerationResult } from "@/types/numbering";
// Types
export interface NumberingTemplate {
template_id: number;
project_id?: number; // Added optional for flexibility in mock, generally required
document_type_name: string; // e.g. Correspondence, RFA
discipline_code?: string; // e.g. STR, ARC, NULL for all
template_format: string; // e.g. {ORG}-{DOCTYPE}-{YYYY}-{SEQ}
example_number: string;
current_number: number;
reset_annually: boolean;
padding_length: number;
is_active: boolean;
}
export interface NumberSequence {
sequence_id: number;
year: number;
organization_code?: string;
discipline_code?: string;
current_number: number;
last_generated_number: string;
updated_at: string;
}
// Mock Data
let mockTemplates: NumberingTemplate[] = [
const mockTemplates: NumberingTemplate[] = [
{
template_id: 1,
document_type_id: "correspondence",
document_type_name: "Correspondence",
discipline_code: "",
template_format: "{ORG}-CORR-{YYYY}-{SEQ}",
example_number: "PAT-CORR-2025-0001",
current_number: 125,
project_id: 1, // LCBP3
document_type_name: 'Correspondence',
discipline_code: '',
template_format: '{ORIGINATOR}-{RECIPIENT}-{SEQ:4}-{YEAR:B.E.}',
example_number: 'PAT-CN-0001-2568',
current_number: 142,
reset_annually: true,
padding_length: 4,
is_active: true,
updated_at: new Date().toISOString(),
},
{
template_id: 2,
document_type_id: "rfa",
document_type_name: "RFA",
discipline_code: "STR",
template_format: "{ORG}-RFA-STR-{YYYY}-{SEQ}",
example_number: "ITD-RFA-STR-2025-0042",
current_number: 42,
project_id: 1, // LCBP3
document_type_name: 'RFA',
discipline_code: 'STR',
template_format: '{PROJECT}-{CORR_TYPE}-{DISCIPLINE}-{RFA_TYPE}-{SEQ:4}-{REV}',
example_number: 'LCBP3-RFA-STR-SDW-0056-A',
current_number: 56,
reset_annually: true,
padding_length: 4,
is_active: true,
},
{
template_id: 3,
project_id: 2, // LCBP3-Maintenance
document_type_name: 'Maintenance Request',
discipline_code: '',
template_format: 'MAINT-{SEQ:4}',
example_number: 'MAINT-0001',
current_number: 1,
reset_annually: true,
padding_length: 4,
is_active: true,
updated_at: new Date(Date.now() - 86400000).toISOString(),
},
];
const mockSequences: NumberingSequence[] = [
const mockSequences: NumberSequence[] = [
{
sequence_id: 1,
template_id: 1,
year: 2025,
organization_code: "PAT",
current_number: 125,
last_generated_number: "PAT-CORR-2025-0125",
organization_code: 'PAT',
current_number: 142,
last_generated_number: 'PAT-CORR-2025-0142',
updated_at: new Date().toISOString(),
},
{
sequence_id: 2,
template_id: 2,
year: 2025,
organization_code: "ITD",
discipline_code: "STR",
current_number: 42,
last_generated_number: "ITD-RFA-STR-2025-0042",
discipline_code: 'STR',
current_number: 56,
last_generated_number: 'RFA-STR-2025-0056',
updated_at: new Date().toISOString(),
},
];
export const numberingApi = {
getTemplates: async (): Promise<NumberingTemplate[]> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return [...mockTemplates];
return new Promise((resolve) => {
setTimeout(() => resolve([...mockTemplates]), 500);
});
},
getTemplate: async (id: number): Promise<NumberingTemplate | undefined> => {
await new Promise((resolve) => setTimeout(resolve, 300));
return mockTemplates.find((t) => t.template_id === id);
return new Promise((resolve) => {
setTimeout(() => resolve(mockTemplates.find(t => t.template_id === id)), 300);
});
},
createTemplate: async (data: CreateTemplateDto): Promise<NumberingTemplate> => {
await new Promise((resolve) => setTimeout(resolve, 800));
const newTemplate: NumberingTemplate = {
template_id: Math.max(...mockTemplates.map((t) => t.template_id)) + 1,
document_type_name: data.document_type_id.toUpperCase(), // Simplified
...data,
example_number: "TEST-0001", // Simplified
current_number: data.starting_number - 1,
is_active: true,
updated_at: new Date().toISOString(),
};
mockTemplates.push(newTemplate);
return newTemplate;
saveTemplate: async (template: Partial<NumberingTemplate>): Promise<NumberingTemplate> => {
return new Promise((resolve) => {
setTimeout(() => {
if (template.template_id) {
// Update
const index = mockTemplates.findIndex(t => t.template_id === template.template_id);
if (index !== -1) {
mockTemplates[index] = { ...mockTemplates[index], ...template } as NumberingTemplate;
resolve(mockTemplates[index]);
}
} else {
// Create
const newTemplate: NumberingTemplate = {
template_id: Math.floor(Math.random() * 1000),
document_type_name: 'New Type',
is_active: true,
current_number: 0,
example_number: 'PREVIEW',
template_format: template.template_format || '',
discipline_code: template.discipline_code,
padding_length: template.padding_length ?? 4,
reset_annually: template.reset_annually ?? true,
...template
} as NumberingTemplate;
mockTemplates.push(newTemplate);
resolve(newTemplate);
}
}, 500);
});
},
updateTemplate: async (id: number, data: Partial<CreateTemplateDto>): Promise<NumberingTemplate> => {
await new Promise((resolve) => setTimeout(resolve, 600));
const index = mockTemplates.findIndex((t) => t.template_id === id);
if (index === -1) throw new Error("Template not found");
const updatedTemplate = { ...mockTemplates[index], ...data, updated_at: new Date().toISOString() };
mockTemplates[index] = updatedTemplate;
return updatedTemplate;
getSequences: async (): Promise<NumberSequence[]> => {
return new Promise((resolve) => {
setTimeout(() => resolve([...mockSequences]), 500);
});
},
getSequences: async (templateId: number): Promise<NumberingSequence[]> => {
await new Promise((resolve) => setTimeout(resolve, 400));
return mockSequences.filter((s) => s.template_id === templateId);
},
generateTestNumber: async (templateId: number, context: { organization_id: string, discipline_id: string }): Promise<{ number: string }> => {
return new Promise((resolve) => {
setTimeout(() => {
const template = mockTemplates.find(t => t.template_id === templateId);
if (!template) return resolve({ number: 'ERROR' });
testTemplate: async (templateId: number, data: any): Promise<TestGenerationResult> => {
await new Promise((resolve) => setTimeout(resolve, 500));
const template = mockTemplates.find(t => t.template_id === templateId);
if (!template) throw new Error("Template not found");
let format = template.template_format;
// Mock replacement
format = format.replace('{PROJECT}', 'LCBP3');
format = format.replace('{ORIGINATOR}', context.organization_id === '1' ? 'PAT' : 'CN');
format = format.replace('{RECIPIENT}', context.organization_id === '1' ? 'CN' : 'PAT');
format = format.replace('{CORR_TYPE}', template.document_type_name === 'Correspondence' ? 'CORR' : 'RFA');
format = format.replace('{DISCIPLINE}', context.discipline_id === '1' ? 'STR' : (context.discipline_id === '2' ? 'ARC' : 'GEN'));
format = format.replace('{RFA_TYPE}', 'SDW'); // Mock
// Mock generation logic
let number = template.template_format;
number = number.replace("{ORG}", data.organization_id === "1" ? "PAT" : "ITD");
number = number.replace("{DOCTYPE}", template.document_type_id.toUpperCase());
number = number.replace("{DISC}", data.discipline_id === "1" ? "STR" : "ARC");
number = number.replace("{YYYY}", data.year.toString());
number = number.replace("{SEQ}", "0001");
const year = new Date().getFullYear();
format = format.replace('{YEAR:A.D.}', year.toString());
format = format.replace('{YEAR:B.E.}', (year + 543).toString());
format = format.replace('{SEQ:4}', '0001');
format = format.replace('{REV}', 'A');
return { number };
},
resolve({ number: format });
}, 800);
});
}
};

View File

@@ -136,6 +136,11 @@ export const {
return token;
}
// If existing token has an error, do not retry refresh (prevents infinite loop)
if (token.error) {
return token;
}
// Token expired, refresh it
return refreshAccessToken(token);
},

View File

@@ -44,7 +44,7 @@ export const useAuthStore = create<AuthState>()(
if (!user) return false;
if (user.permissions?.includes(requiredPermission)) return true;
if (user.role === 'Admin') return true;
if (['Admin', 'ADMIN', 'admin'].includes(user.role)) return true;
return false;
},

View File

@@ -4,6 +4,15 @@ export interface Organization {
org_code: string;
}
export interface Attachment {
id: number;
name: string;
url: string;
size?: number;
type?: string;
created_at?: string;
}
export interface Correspondence {
correspondence_id: number;
document_number: string;
@@ -18,7 +27,7 @@ export interface Correspondence {
from_organization?: Organization;
to_organization?: Organization;
document_type_id: number;
attachments?: any[]; // Define Attachment type if needed
attachments?: Attachment[];
}
export interface CreateCorrespondenceDto {

44
pnpm-lock.yaml generated
View File

@@ -344,6 +344,9 @@ importers:
next-auth:
specifier: 5.0.0-beta.30
version: 5.0.0-beta.30(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: ^18
version: 18.3.1
@@ -359,6 +362,9 @@ importers:
reactflow:
specifier: ^11.11.4
version: 11.11.4(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -5212,6 +5218,12 @@ packages:
nodemailer:
optional: true
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.0.7:
resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
engines: {node: '>=20.9.0'}
@@ -5870,6 +5882,12 @@ packages:
resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==}
engines: {node: '>=10.2.0'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -10837,8 +10855,8 @@ snapshots:
'@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -10861,7 +10879,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -10872,22 +10890,22 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -10898,7 +10916,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -12423,6 +12441,11 @@ snapshots:
next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 16.0.7
@@ -13158,6 +13181,11 @@ snapshots:
- supports-color
- utf-8-validate
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
source-map-js@1.2.1: {}
source-map-support@0.5.13:

View File

@@ -25,23 +25,39 @@
```
backend/
├── src/
│ ├── common/ # Shared utilities, decorators, guards
│ │ ├── auth/ # Authentication module
│ │ ├── config/ # Configuration management
│ ├── common/ # Shared utilities
│ │ ├── decorators/ # Custom decorators
│ │ ├── dtos/ # Common DTOs
│ │ ├── entities/ # Base entities
│ │ ├── filters/ # Exception filters
│ │ ├── guards/ # Auth guards, RBAC
│ │ ├── interceptors/ # Logging, transform, idempotency
│ │ ── file-storage/ # Two-phase file storage
│ │ ── interfaces/ # Common interfaces
│ │ └── utils/ # Helper functions
│ ├── config/ # Configuration management
│ ├── database/
│ │ ├── migrations/
│ │ └── seeds/
│ ├── modules/ # Business modules (domain-driven)
│ │ ├── user/
│ │ ├── project/
│ │ ├── auth/
│ │ ├── circulation/
│ │ ├── correspondence/
│ │ ├── dashboard/
│ │ ├── document-numbering/
│ │ ├── drawing/
│ │ ├── json-schema/
│ │ ├── master/
│ │ ├── monitoring/
│ │ ├── notification/
│ │ ├── organizations/
│ │ ├── project/
│ │ ├── rfa/
│ │ ├── workflow-engine/
│ │ ── ...
└── database/
── migrations/
└── seeds/
│ │ ├── search/
│ │ ── transmittal/
│ ├── user/
── workflow-engine/
├── app.module.ts
│ └── main.ts
├── test/ # E2E tests
└── scripts/ # Utility scripts
```

View File

@@ -35,13 +35,17 @@ frontend/
│ ├── forms/ # Form components
│ ├── layout/ # Layout components (Navbar, Sidebar)
│ └── tables/ # Data table components
├── hooks/ # Custom React hooks (Root level)
├── lib/
│ ├── api/ # API client (Axios)
│ ├── hooks/ # Custom React hooks
│ ├── services/ # API service functions
── stores/ # Zustand stores
── stores/ # Zustand stores
│ └── utils.ts # Cn utility
├── providers/ # Context providers
├── public/ # Static assets
├── styles/ # Global styles
├── types/ # TypeScript types & DTOs
└── providers/ # Context providers
└── middleware.ts # Next.js Middleware
```
---

View File

@@ -28,7 +28,7 @@
1. **Database Schema:**
- ✅ ทุกตารางถูกสร้างตาม Data Dictionary v1.4.5
- ✅ ทุกตารางถูกสร้างตาม Data Dictionary v1.5.1
- ✅ Foreign Keys ถูกต้องครบถ้วน
- ✅ Indexes ครบตาม Specification
- ✅ Virtual Columns สำหรับ JSON fields

View File

@@ -1,6 +1,6 @@
# Task: Document Numbering Service
**Status:** Not Started
**Status:** Ready for Implementation
**Priority:** P1 (High - Critical for Documents)
**Estimated Effort:** 7-8 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth), TASK-BE-003 (Redis Setup)
@@ -50,14 +50,18 @@
- ✅ Fallback to DB pessimistic lock when Redis unavailable
- ✅ Retry with exponential backoff (5 retries max for lock, 2 for version conflict, 3 for DB errors)
### 3. Document Types Support
### 3. Document Types Support & Scoping
- ✅ LETTER / RFI / MEMO / EMAIL / MOM / INSTRUCTION / NOTICE / OTHER
-**General Correspondence** (LETTER / MEMO / etc.) → **Project Level Scope**
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)`
- ✅ TRANSMITTAL
- *Note*: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
-**Transmittal****Project Level Scope** with Sub-Type lookup
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, sub_type_id, 0, 0, year)`
- ✅ RFA
-**RFA****Contract Level Scope** (Implicit)
- Counter Key: `(project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, discipline_id, year)`
- *Mechanism*: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
### 4. Error Handling
@@ -85,41 +89,33 @@
### Step 1: Database Entities
#### 1.1 Document Number Config Entity
```typescript
// File: backend/src/modules/document-numbering/entities/document-number-config.entity.ts
// File: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Project } from '../../project/entities/project.entity';
import { DocumentType } from '../../document-type/entities/document-type.entity';
import { CorrespondenceType } from '../../correspondence-type/entities/correspondence-type.entity';
@Entity('document_number_configs')
export class DocumentNumberConfig {
@Entity('document_number_formats')
export class DocumentNumberFormat {
@PrimaryGeneratedColumn()
id: number;
@Column()
project_id: number;
@Column()
doc_type_id: number;
@Column({ name: 'correspondence_type_id' })
correspondenceTypeId: number;
@Column({ default: 0, comment: 'ประเภทย่อย (nullable, use 0 for fallback)' })
sub_type_id: number;
// Note: Schema currently only has project_id + correspondence_type_id.
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
@Column({ default: 0, comment: 'สาขาวิชา (nullable, use 0 for fallback)' })
discipline_id: number;
@Column({ length: 255, comment: 'e.g. {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV}' })
template: string;
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
formatTemplate: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ default: 0, comment: 'For template versioning' })
version: number;
@CreateDateColumn()
created_at: Date;
@@ -130,11 +126,10 @@ export class DocumentNumberConfig {
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => DocumentType)
@JoinColumn({ name: 'doc_type_id' })
documentType: DocumentType;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType: CorrespondenceType;
}
```
#### 1.2 Document Number Counter Entity
@@ -158,8 +153,8 @@ export class DocumentNumberCounter {
@PrimaryColumn({ name: 'originator_organization_id' })
originatorOrganizationId: number;
@PrimaryColumn({ name: 'recipient_organization_id', nullable: true })
recipientOrganizationId: number | null; // NULL for RFA
@PrimaryColumn({ name: 'recipient_organization_id', default: -1 })
recipientOrganizationId: number; // -1 if NULL (standardized for composite key)
@PrimaryColumn({ name: 'correspondence_type_id' })
correspondenceTypeId: number;
@@ -189,7 +184,7 @@ export class DocumentNumberCounter {
> **⚠️ หมายเหตุ Schema:**
>
> - Primary Key ใช้ `COALESCE(recipient_organization_id, 0)` ในการสร้าง constraint (ดู migration file)
> - Primary Key ใช้ `recipient_organization_id = -1` แทน NULL (ตาม Schema v1.5.1)
> - `sub_type_id`, `rfa_type_id`, `discipline_id` ใช้ `0` แทน NULL
> - Counter reset อัตโนมัติทุกปี (แยก counter ตาม `current_year`)
@@ -309,7 +304,7 @@ import { Repository, DataSource } from 'typeorm';
import Redlock from 'redlock';
import Redis from 'ioredis';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberConfig } from './entities/document-number-config.entity';
import { DocumentNumberFormat } from './entities/document-number-format.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
import { GenerateNumberDto } from './dto/generate-number.dto';
import { MetricsService } from '../metrics/metrics.service';
@@ -321,8 +316,8 @@ export class DocumentNumberingService {
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberConfig)
private configRepo: Repository<DocumentNumberConfig>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
@InjectRepository(DocumentNumberAudit)
private auditRepo: Repository<DocumentNumberAudit>,
private dataSource: DataSource,
@@ -470,8 +465,8 @@ export class DocumentNumberingService {
}
// Step 4: Get config and format number
const config = await this.getConfig(dto.projectId, dto.docTypeId, subTypeId, disciplineId);
const formattedNumber = await this.formatNumber(config.template, {
const config = await this.getConfig(dto.projectId, dto.docTypeId);
const formattedNumber = await this.formatNumber(config.formatTemplate, {
projectId: dto.projectId,
docTypeId: dto.docTypeId,
subTypeId,
@@ -561,8 +556,8 @@ export class DocumentNumberingService {
}
// Format number
const config = await this.getConfig(dto.projectId, dto.docTypeId, subTypeId, disciplineId);
const formattedNumber = await this.formatNumber(config.template, {
const config = await this.getConfig(dto.projectId, dto.docTypeId);
const formattedNumber = await this.formatNumber(config.formatTemplate, {
projectId: dto.projectId,
docTypeId: dto.docTypeId,
subTypeId,
@@ -576,7 +571,7 @@ export class DocumentNumberingService {
await manager.save(DocumentNumberAudit, {
generated_number: formattedNumber,
counter_key: `db_lock:${dto.projectId}:${dto.docTypeId}`,
template_used: config.template,
template_used: config.formatTemplate,
sequence_number: nextNumber,
user_id: dto.userId,
ip_address: dto.ipAddress,
@@ -596,8 +591,9 @@ export class DocumentNumberingService {
private async formatNumber(template: string, data: any): Promise<string> {
const tokens = {
'{PROJECT}': await this.getProjectCode(data.projectId),
'{ORG}': await this.getOrgCode(data.organizationId),
'{TYPE}': await this.getTypeCode(data.docTypeId),
'{ORIGINATOR}': await this.getOriginatorOrgCode(data.originatorOrganizationId),
'{RECIPIENT}': await this.getRecipientOrgCode(data.recipientOrganizationId),
'{CORR_TYPE}': await this.getTypeCode(data.docTypeId),
'{SUB_TYPE}': await this.getSubTypeCode(data.subTypeId),
'{DISCIPLINE}': await this.getDisciplineCode(data.disciplineId),
'{CATEGORY}': await this.getCategoryCode(data.categoryId),
@@ -661,39 +657,26 @@ export class DocumentNumberingService {
}
/**
* Get configuration template
* Get configuration template (Format)
*/
private async getConfig(
projectId: number,
docTypeId: number,
subTypeId: number,
disciplineId: number,
): Promise<DocumentNumberConfig> {
// Try exact match first
let config = await this.configRepo.findOne({
correspondenceTypeId: number,
): Promise<DocumentNumberFormat> {
// Note: Schema currently only separates by project_id and correspondence_type_id
// If we need sub-type specific templates, we should check if they are supported in the future schema.
// Converting old logic slightly to match v1.5.1 schema columns.
const config = await this.formatRepo.findOne({
where: {
project_id: projectId,
doc_type_id: docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
correspondenceTypeId: correspondenceTypeId,
},
});
// Fallback to default (subTypeId=0, disciplineId=0)
if (!config) {
config = await this.configRepo.findOne({
where: {
project_id: projectId,
doc_type_id: docTypeId,
sub_type_id: 0,
discipline_id: 0,
},
});
}
if (!config) {
throw new NotFoundException(
`Document number config not found for project=${projectId}, docType=${docTypeId}`
`Document number format not found for project=${projectId}, type=${correspondenceTypeId}`
);
}
@@ -1241,14 +1224,14 @@ ensure:
## 🚨 Risks & Mitigation
| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| Redis lock failure | High | Low | Automatic fallback to DB lock |
| Version conflicts under high load | Medium | Medium | Exponential backoff retry (2x) |
| Lock timeout | Medium | Low | Retry 5x with exponential backoff |
| Performance degradation | High | Medium | Redis caching, connection pooling, monitoring |
| DB connection pool exhaustion | High | Low | Retry 3x, increase pool size, monitoring |
| Rate limit bypass | Medium | Low | Multi-layer limiting (user + IP + global) |
| Risk | Impact | Probability | Mitigation |
| --------------------------------- | ------ | ----------- | --------------------------------------------- |
| Redis lock failure | High | Low | Automatic fallback to DB lock |
| Version conflicts under high load | Medium | Medium | Exponential backoff retry (2x) |
| Lock timeout | Medium | Low | Retry 5x with exponential backoff |
| Performance degradation | High | Medium | Redis caching, connection pooling, monitoring |
| DB connection pool exhaustion | High | Low | Retry 3x, increase pool size, monitoring |
| Rate limit bypass | Medium | Low | Multi-layer limiting (user + IP + global) |
---
@@ -1292,7 +1275,7 @@ Stored in database (`document_number_configs` table), configurable per:
## 🔄 Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2025-11-30 | Initial task definition |
| 2.0 | 2025-12-02 | Comprehensive update with all 9 tokens, 4 document types, 4 error scenarios, audit logging, monitoring, rate limiting, and complete implementation details |
| Version | Date | Changes |
| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0 | 2025-11-30 | Initial task definition |
| 2.0 | 2025-12-02 | Comprehensive update with all 9 tokens, 4 document types, 4 error scenarios, audit logging, monitoring, rate limiting, and complete implementation details |

View File

@@ -1,6 +1,6 @@
# Task: Workflow Engine Module
**Status:** Not Started
**Status:** Completed
**Priority:** P0 (Critical - Core Infrastructure)
**Estimated Effort:** 10-14 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)

View File

@@ -1,6 +1,6 @@
# Task: RFA Module
**Status:** Not Started
**Status:** In Progress
**Priority:** P1 (High - Core Business Module)
**Estimated Effort:** 8-12 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004, TASK-BE-006

View File

@@ -1,6 +1,6 @@
# Task: Drawing Module (Shop & Contract Drawings)
**Status:** Not Started
**Status:** In Progress
**Priority:** P2 (Medium - Supporting Module)
**Estimated Effort:** 6-8 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004

View File

@@ -1,6 +1,6 @@
# Task: Circulation & Transmittal Modules
**Status:** Not Started
**Status:** In Progress
**Priority:** P2 (Medium)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-006

View File

@@ -1,6 +1,6 @@
# Task: Search & Elasticsearch Integration
**Status:** Not Started
**Status:** 🚧 In Progress
**Priority:** P2 (Medium - Performance Enhancement)
**Estimated Effort:** 4-6 days
**Dependencies:** TASK-BE-001, TASK-BE-005, TASK-BE-007
@@ -16,11 +16,11 @@
## 🎯 Objectives
- Elasticsearch Integration
- Full-text Search (Correspondences, RFAs, Drawings)
- Advanced Filters
- Search Result Aggregations
- Auto-indexing
- [x] Elasticsearch Integration
- [x] Full-text Search (Correspondences, RFAs, Drawings)
- [x] Advanced Filters
- [ ] Search Result Aggregations (Pending verification)
- [x] Auto-indexing (Implemented via Direct Call, not Queue yet)
---
@@ -28,21 +28,21 @@
1. **Search Capabilities:**
- Search across multiple document types
- Full-text search in title, description
- Filter by project, status, date range
- Sort results by relevance/date
- [x] Search across multiple document types
- [x] Full-text search in title, description
- [x] Filter by project, status, date range
- [x] Sort results by relevance/date
2. **Indexing:**
- Auto-index on document create/update
- Async indexing (via queue)
- Bulk re-indexing command
- [x] Auto-index on document create/update (Direct Call implemented)
- [ ] Async indexing (via queue) - **Pending**
- [ ] Bulk re-indexing command - **Pending**
3. **Performance:**
- Search results < 500ms
- Pagination support
- Highlight search terms
- [x] Search results < 500ms
- [x] Pagination support
- [x] Highlight search terms
---
@@ -462,12 +462,12 @@ describe('SearchService', () => {
## 📦 Deliverables
- [ ] SearchService with Elasticsearch
- [ ] Search Indexer (Queue Worker)
- [ ] Index Mappings
- [ ] Queue Integration
- [ ] Search Controller
- [ ] Bulk Re-indexing Command
- [x] SearchService with Elasticsearch
- [ ] Search Indexer (Queue Worker) - **Pending**
- [x] Index Mappings (Implemented in Service)
- [ ] Queue Integration - **Pending**
- [x] Search Controller
- [ ] Bulk Re-indexing Command - **Pending**
- [ ] Unit Tests (75% coverage)
- [ ] API Documentation

View File

@@ -1,6 +1,6 @@
# Task: Notification & Audit Log Services
**Status:** Not Started
**Status:** Completed
**Priority:** P3 (Low - Supporting Services)
**Estimated Effort:** 3-5 days
**Dependencies:** TASK-BE-001, TASK-BE-002

View File

@@ -1,6 +1,6 @@
# Task: Master Data Management Module
**Status:** Not Started
**Status:** Completed
**Priority:** P1 (High - Required for System Setup)
**Estimated Effort:** 6-8 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth)

View File

@@ -1,6 +1,6 @@
# Task: User Management Module
**Status:** Not Started
**Status:** Completed
**Priority:** P1 (High - Core User Features)
**Estimated Effort:** 5-7 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth & RBAC)

View File

@@ -29,11 +29,11 @@ Build UI for configuring and managing workflows using the DSL-based workflow eng
## ✅ Acceptance Criteria
- [ ] List all workflows with status
- [ ] Create/edit workflows with DSL editor
- [ ] Visual workflow builder functional
- [ ] DSL validation shows errors
- [ ] Test workflow with sample data
- [x] List all workflows with status
- [x] Create/edit workflows with DSL editor
- [x] Visual workflow builder functional
- [x] DSL validation shows errors
- [x] Test workflow with sample data
- [ ] Workflow templates available
- [ ] Version history viewable

View File

@@ -29,13 +29,13 @@ Build UI for configuring and managing document numbering templates including tem
## ✅ Acceptance Criteria
- [ ] List all numbering templates by document type
- [ ] Create/edit templates with format preview
- [ ] Template variables easily selectable
- [ ] Preview shows example numbers
- [ ] View current number sequences
- [ ] Annual reset configurable
- [ ] Validation prevents conflicts
- [x] List all numbering templates by document type
- [x] Create/edit templates with format preview
- [x] Template variables easily selectable
- [x] Preview shows example numbers
- [x] View current number sequences
- [x] Annual reset configurable
- [x] Validation prevents conflicts
---
@@ -67,10 +67,21 @@ export default function NumberingPage() {
Manage document numbering templates and sequences
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
<div className="flex gap-2">
<Select defaultValue="1">
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">LCBP3</SelectItem>
<SelectItem value="2">LCBP3-Maintenance</SelectItem>
</SelectContent>
</Select>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
</div>
<div className="grid gap-4">
@@ -161,14 +172,16 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
const VARIABLES = [
{ key: '{ORG}', name: 'Organization Code', example: 'กทท' },
{ key: '{DOCTYPE}', name: 'Document Type', example: 'CORR' },
{ key: '{DISC}', name: 'Discipline', example: 'STR' },
{ key: '{YYYY}', name: 'Year (4-digit)', example: '2025' },
{ key: '{YY}', name: 'Year (2-digit)', example: '25' },
{ key: '{MM}', name: 'Month', example: '12' },
{ key: '{SEQ}', name: 'Sequence Number', example: '0001' },
{ key: '{CONTRACT}', name: 'Contract Code', example: 'C01' },
{ key: '{ORIGINATOR}', name: 'Originator Code', example: 'PAT' },
{ key: '{RECIPIENT}', name: 'Recipient Code', example: 'CN' },
{ key: '{CORR_TYPE}', name: 'Correspondence Type', example: 'RFA' },
{ key: '{SUB_TYPE}', name: 'Sub Type', example: '21' },
{ key: '{DISCIPLINE}', name: 'Discipline', example: 'STR' },
{ key: '{RFA_TYPE}', name: 'RFA Type', example: 'SDW' },
{ key: '{YEAR:B.E.}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR:A.D.}', name: 'Year (A.D.)', example: '2025' },
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
{ key: '{REV}', name: 'Revision', example: 'A' },
];
export function TemplateEditor({ template, onSave }: any) {
@@ -201,9 +214,16 @@ export function TemplateEditor({ template, onSave }: any) {
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="drawing">Drawing</SelectItem>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="RFI">RFI</SelectItem>
<SelectItem value="TRANSMITTAL">Transmittal</SelectItem>
<SelectItem value="LETTER">Letter</SelectItem>
<SelectItem value="MEMO">Memorandum</SelectItem>
<SelectItem value="EMAIL">Email</SelectItem>
<SelectItem value="MOM">Minutes of Meeting</SelectItem>
<SelectItem value="INSTRUCTION">Instruction</SelectItem>
<SelectItem value="NOTICE">Notice</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -5,21 +5,21 @@
## 📊 Overview
| Task ID | Title | Status | Completion % | Notes |
| --------------- | ------------------------- | ----------------- | ------------ | ------------------------------------------------------ |
| **TASK-BE-001** | Database Migrations | ✅ **Done** | 100% | Schema v1.5.1 active. TypeORM configured. |
| **TASK-BE-002** | Auth & RBAC | ✅ **Done** | 100% | JWT, Refresh Token, RBAC Guard, Permissions complete. |
| **TASK-BE-003** | File Storage | ✅ **Done** | 100% | MinIO/S3 strategies implemented (in `common`). |
| **TASK-BE-004** | Document Numbering | ✅ **Done** | 100% | **High Quality**: Redlock + Optimistic Locking logic. |
| **TASK-BE-005** | Correspondence Module | ✅ **Done** | 95% | CRUD, Workflow Submit, References, Audit Log complete. |
| **TASK-BE-006** | Workflow Engine | ✅ **Done** | 100% | DSL Evaluator, Versioning, Event Dispatching complete. |
| **TASK-BE-007** | RFA Module | ✅ **Done** | 95% | Full Swagger, Revision handling, Workflow integration. |
| **TASK-BE-008** | Drawing Module | ✅ **Done** | 95% | Split into `ShopDrawing` & `ContractDrawing`. |
| **TASK-BE-009** | Circulation & Transmittal | ✅ **Done** | 90% | Modules exist and registered in `app.module.ts`. |
| **TASK-BE-010** | Search (Elasticsearch) | 🚧 **In Progress** | 80% | Module registered, needs deep verification of mapping. |
| **TASK-BE-011** | Notification & Audit | ✅ **Done** | 100% | Global Audit Interceptor & Notification Module active. |
| **TASK-BE-012** | Master Data Management | ✅ **Done** | 100% | Disciplines, SubTypes, Tags, Config APIs complete. |
| **TASK-BE-013** | User Management | ✅ **Done** | 100% | CRUD, Assignments, Preferences, Soft Delete complete. |
| Task ID | Title | Status | Completion % | Notes |
| --------------- | ------------------------- | ----------------- | ------------ | ----------------------------------------------------------------------- |
| **TASK-BE-001** | Database Migrations | ✅ **Done** | 100% | Schema v1.5.1 active. TypeORM configured. |
| **TASK-BE-002** | Auth & RBAC | ✅ **Done** | 100% | JWT, Refresh Token, RBAC Guard, Permissions complete. |
| **TASK-BE-003** | File Storage | ✅ **Done** | 100% | MinIO/S3 strategies implemented (in `common`). |
| **TASK-BE-004** | Document Numbering | ✅ **Done** | 100% | **High Quality**: Redlock + Optimistic Locking logic. |
| **TASK-BE-005** | Correspondence Module | ✅ **Done** | 95% | CRUD, Workflow Submit, References, Audit Log complete. |
| **TASK-BE-006** | Workflow Engine | ✅ **Done** | 100% | DSL Evaluator, Versioning, Event Dispatching complete. |
| **TASK-BE-007** | RFA Module | ✅ **Done** | 95% | Full Swagger, Revision handling, Workflow integration. |
| **TASK-BE-008** | Drawing Module | ✅ **Done** | 95% | Split into `ShopDrawing` & `ContractDrawing`. |
| **TASK-BE-009** | Circulation & Transmittal | ✅ **Done** | 90% | Modules exist and registered in `app.module.ts`. |
| **TASK-BE-010** | Search (Elasticsearch) | 🚧 **In Progress** | 70% | Basic search working (Direct Indexing). Missing: Queue & Bulk Re-index. |
| **TASK-BE-011** | Notification & Audit | ✅ **Done** | 100% | Global Audit Interceptor & Notification Module active. |
| **TASK-BE-012** | Master Data Management | ✅ **Done** | 100% | Disciplines, SubTypes, Tags, Config APIs complete. |
| **TASK-BE-013** | User Management | ✅ **Done** | 100% | CRUD, Assignments, Preferences, Soft Delete complete. |
## 🛠 Detailed Findings by Component

View File

@@ -16,12 +16,12 @@ FOREIGN KEYS (FK),
| id | INT | PRIMARY KEY,
AUTO_INCREMENT | UNIQUE identifier FOR organization role | | role_name | VARCHAR(20) | NOT NULL,
UNIQUE | Role name (
OWNER,
DESIGNER,
CONSULTANT,
CONTRACTOR,
THIRD PARTY
) | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (role_name) ** Business Rules **: - Predefined system roles FOR organization TYPES - Cannot be deleted IF referenced by organizations ---
) |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (role_name) ** Business Rules **: - Predefined system roles FOR organization TYPES - Cannot be deleted IF referenced by organizations ---
### 1.2 organizations
@@ -29,7 +29,8 @@ UNIQUE | Role name (
| id | INT | PRIMARY KEY,
AUTO_INCREMENT | UNIQUE identifier FOR organization | | organization_code | VARCHAR(20) | NOT NULL,
UNIQUE | Organization code (e.g., 'กทท.', 'TEAM') | | organization_name | VARCHAR(255) | NOT NULL | FULL organization name | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS (1 = active, 0 = inactive) | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last
UPDATE timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships **: - Referenced by: users,
UPDATE timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships **: - Referenced by: users,
project_organizations,
contract_organizations,
correspondences,
@@ -40,7 +41,11 @@ UPDATE timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code
* * Purpose **: MASTER TABLE FOR ALL projects IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ------------ | ------------ | --------------------------- | ----------------------------- |
| id | INT | PRIMARY KEY,
AUTO_INCREMENT | UNIQUE identifier FOR project | | project_code | VARCHAR(50) | NOT NULL,
UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (project_code) - INDEX (is_active) ** Relationships **: - Referenced by: contracts,
UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp |
** INDEXES **: - PRIMARY KEY (id) - UNIQUE (project_code) - INDEX (is_active) ** Relationships **: - Referenced by: contracts,
correspondences,
document_number_formats,
drawings ---
@@ -53,7 +58,8 @@ UPDATE timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code
FK | Reference TO projects TABLE | | contract_code | VARCHAR(50) | NOT NULL,
UNIQUE | Contract code | | contract_name | VARCHAR(255) | NOT NULL | FULL contract name | | description | TEXT | NULL | Contract description | | start_date | DATE | NULL | Contract START date | | end_date | DATE | NULL | Contract
END date | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last
UPDATE timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (contract_code) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - INDEX (project_id, is_active) ** Relationships **: - Parent: projects - Referenced by: contract_organizations,
UPDATE timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (contract_code) - FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE - INDEX (project_id, is_active) ** Relationships **: - Parent: projects - Referenced by: contract_organizations,
user_assignments ---
### 1.5 disciplines (NEW v1.5.1)
@@ -87,7 +93,11 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
AUTO_INCREMENT | UNIQUE identifier FOR role | | role_name | VARCHAR(100) | NOT NULL | Role name (e.g., 'Superadmin', 'Document Control') | | scope | ENUM | NOT NULL | Scope LEVEL: GLOBAL,
Organization,
Project,
Contract | | description | TEXT | NULL | Role description | | is_system | BOOLEAN | DEFAULT FALSE | System role flag (cannot be deleted) | ** INDEXES **: - PRIMARY KEY (role_id) - INDEX (scope) ** Relationships **: - Referenced by: role_permissions,
Contract | | description | TEXT | NULL | Role description | | is_system | BOOLEAN | DEFAULT FALSE | System role flag (cannot be deleted) |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp |
** INDEXES **: - PRIMARY KEY (role_id) - INDEX (scope) ** Relationships **: - Referenced by: role_permissions,
user_assignments ---
### 2.3 permissions
@@ -97,7 +107,11 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
AUTO_INCREMENT | UNIQUE identifier FOR permission | | permission_name | VARCHAR(100) | NOT NULL,
UNIQUE | Permission code (e.g., 'rfas.create', 'document.view') | | description | TEXT | NULL | Permission description | | module | VARCHAR(50) | NULL | Related module name | | scope_level | ENUM | NULL | Scope: GLOBAL,
ORG,
PROJECT | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | ** INDEXES **: - PRIMARY KEY (permission_id) - UNIQUE (permission_name) - INDEX (module) - INDEX (scope_level) - INDEX (is_active) ** Relationships **: - Referenced by: role_permissions ---
PROJECT | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp |
** INDEXES **: - PRIMARY KEY (permission_id) - UNIQUE (permission_name) - INDEX (module) - INDEX (scope_level) - INDEX (is_active) ** Relationships **: - Referenced by: role_permissions ---
### 2.4 role_permissions
@@ -205,6 +219,9 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| type_name | VARCHAR(255) | NOT NULL | Full type name |
| sort_order | INT | DEFAULT 0 | Display order |
| is_active | TINYINT(1) | DEFAULT 1 | Active status |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Last update timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp |
**Indexes**:

View File

@@ -204,7 +204,10 @@ DROP TABLE IF EXISTS organizations;
-- ตาราง Master เก็บประเภทบทบาทขององค์กร
CREATE TABLE organization_roles (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
role_name VARCHAR(20) NOT NULL UNIQUE COMMENT 'ชื่อบทบาท (OWNER, DESIGNER, CONSULTANT, CONTRACTOR, THIRD PARTY)'
role_name VARCHAR(20) NOT NULL UNIQUE COMMENT 'ชื่อบทบาท (OWNER, DESIGNER, CONSULTANT, CONTRACTOR, THIRD PARTY)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บประเภทบทบาทขององค์กร';
-- ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ
@@ -216,6 +219,7 @@ CREATE TABLE organizations (
is_active BOOLEAN DEFAULT TRUE COMMENT 'สถานะการใช้งาน',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)',
FOREIGN KEY (role_id) REFERENCES organization_roles (id) ON DELETE
SET NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ';
@@ -227,8 +231,12 @@ CREATE TABLE projects (
project_name VARCHAR(255) NOT NULL COMMENT 'ชื่อโครงการ',
-- parent_project_id INT COMMENT 'รหัสโครงการหลัก (ถ้ามี)',
-- contractor_organization_id INT COMMENT 'รหัสองค์กรผู้รับเหมา (ถ้ามี)',
is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน' -- FOREIGN KEY (parent_project_id) REFERENCES projects(id) ON DELETE SET NULL,
is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน',
-- FOREIGN KEY (parent_project_id) REFERENCES projects(id) ON DELETE SET NULL,
-- FOREIGN KEY (contractor_organization_id) REFERENCES organizations(id) ON DELETE SET NULL
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลโครงการ';
-- ตาราง Master เก็บข้อมูลสัญญา
@@ -243,6 +251,7 @@ CREATE TABLE contracts (
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)',
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลสัญญา';
@@ -295,7 +304,10 @@ CREATE TABLE roles (
) NOT NULL,
-- ขอบเขตของบทบาท (จากข้อ 4.3)
description TEXT COMMENT 'คำอธิบายบทบาท',
is_system BOOLEAN DEFAULT FALSE COMMENT '(1 = บทบาทของระบบ ลบไม่ได้)'
is_system BOOLEAN DEFAULT FALSE COMMENT '(1 = บทบาทของระบบ ลบไม่ได้)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บ "บทบาท" ของผู้ใช้ในระบบ';
-- ตาราง Master เก็บ "สิทธิ์" (Permission) หรือ "การกระทำ" ทั้งหมดในระบบ
@@ -305,7 +317,10 @@ CREATE TABLE permissions (
description TEXT COMMENT 'คำอธิบายสิทธิ์',
module VARCHAR(50) COMMENT 'โมดูลที่เกี่ยวข้อง',
scope_level ENUM('GLOBAL', 'ORG', 'PROJECT') COMMENT 'ระดับขอบเขตของสิทธิ์',
is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน'
is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บ "สิทธิ์" (Permission) หรือ "การกระทำ" ทั้งหมดในระบบ';
-- ตารางเชื่อมระหว่าง roles และ permissions (M:N)
@@ -388,7 +403,10 @@ CREATE TABLE correspondence_types (
type_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสประเภท (เช่น RFA, RFI)',
type_name VARCHAR(255) NOT NULL COMMENT 'ชื่อประเภท',
sort_order INT DEFAULT 0 COMMENT 'ลำดับการแสดงผล',
is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน '
is_active TINYINT(1) DEFAULT 1 COMMENT 'สถานะการใช้งาน ',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บประเภทเอกสารโต้ตอบ';
-- ตาราง Master เก็บสถานะของเอกสาร

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
-- ==========================================================
-- Permission System Verification Queries
-- File: specs/07-database/permissions-verification.sql
-- Purpose: Verify permissions setup after seed data deployment
-- ==========================================================
-- ==========================================================
-- 1. COUNT PERMISSIONS PER CATEGORY
-- ==========================================================
SELECT CASE
WHEN permission_id BETWEEN 1 AND 10 THEN '001-010: System & Global'
WHEN permission_id BETWEEN 11 AND 20 THEN '011-020: Organization Management'
WHEN permission_id BETWEEN 21 AND 40 THEN '021-040: User & Role Management'
WHEN permission_id BETWEEN 41 AND 50 THEN '041-050: Master Data Management'
WHEN permission_id BETWEEN 51 AND 70 THEN '051-070: Document Management (Generic)'
WHEN permission_id BETWEEN 71 AND 80 THEN '071-080: Correspondence Module'
WHEN permission_id BETWEEN 81 AND 90 THEN '081-090: RFA Module'
WHEN permission_id BETWEEN 91 AND 100 THEN '091-100: Drawing Module'
WHEN permission_id BETWEEN 101 AND 110 THEN '101-110: Circulation Module'
WHEN permission_id BETWEEN 111 AND 120 THEN '111-120: Transmittal Module'
WHEN permission_id BETWEEN 121 AND 130 THEN '121-130: Workflow Engine'
WHEN permission_id BETWEEN 131 AND 140 THEN '131-140: Document Numbering'
WHEN permission_id BETWEEN 141 AND 150 THEN '141-150: Search & Reporting'
WHEN permission_id BETWEEN 151 AND 160 THEN '151-160: Notification & Dashboard'
WHEN permission_id BETWEEN 161 AND 170 THEN '161-170: JSON Schema Management'
WHEN permission_id BETWEEN 171 AND 180 THEN '171-180: Monitoring & Admin Tools'
WHEN permission_id BETWEEN 201 AND 220 THEN '201-220: Project & Contract Management'
ELSE 'Unknown Range'
END AS category_range,
COUNT(*) AS permission_count
FROM permissions
GROUP BY CASE
WHEN permission_id BETWEEN 1 AND 10 THEN '001-010: System & Global'
WHEN permission_id BETWEEN 11 AND 20 THEN '011-020: Organization Management'
WHEN permission_id BETWEEN 21 AND 40 THEN '021-040: User & Role Management'
WHEN permission_id BETWEEN 41 AND 50 THEN '041-050: Master Data Management'
WHEN permission_id BETWEEN 51 AND 70 THEN '051-070: Document Management (Generic)'
WHEN permission_id BETWEEN 71 AND 80 THEN '071-080: Correspondence Module'
WHEN permission_id BETWEEN 81 AND 90 THEN '081-090: RFA Module'
WHEN permission_id BETWEEN 91 AND 100 THEN '091-100: Drawing Module'
WHEN permission_id BETWEEN 101 AND 110 THEN '101-110: Circulation Module'
WHEN permission_id BETWEEN 111 AND 120 THEN '111-120: Transmittal Module'
WHEN permission_id BETWEEN 121 AND 130 THEN '121-130: Workflow Engine'
WHEN permission_id BETWEEN 131 AND 140 THEN '131-140: Document Numbering'
WHEN permission_id BETWEEN 141 AND 150 THEN '141-150: Search & Reporting'
WHEN permission_id BETWEEN 151 AND 160 THEN '151-160: Notification & Dashboard'
WHEN permission_id BETWEEN 161 AND 170 THEN '161-170: JSON Schema Management'
WHEN permission_id BETWEEN 171 AND 180 THEN '171-180: Monitoring & Admin Tools'
WHEN permission_id BETWEEN 201 AND 220 THEN '201-220: Project & Contract Management'
ELSE 'Unknown Range'
END
ORDER BY MIN(permission_id);
-- ==========================================================
-- 2. COUNT PERMISSIONS PER ROLE
-- ==========================================================
SELECT r.role_id,
r.role_name,
r.scope,
COUNT(rp.permission_id) AS permission_count
FROM roles r
LEFT JOIN role_permissions rp ON r.role_id = rp.role_id
GROUP BY r.role_id,
r.role_name,
r.scope
ORDER BY r.role_id;
-- ==========================================================
-- 3. CHECK TOTAL PERMISSION COUNT
-- ==========================================================
SELECT 'Total Permissions' AS metric,
COUNT(*) AS COUNT
FROM permissions
UNION ALL
SELECT 'Active Permissions',
COUNT(*)
FROM permissions
WHERE is_active = 1;
-- ==========================================================
-- 4. CHECK FOR MISSING PERMISSIONS (Used in Code but Not in DB)
-- ==========================================================
-- List of permissions actually used in controllers
WITH code_permissions AS (
SELECT 'system.manage_all' AS permission_name
UNION
SELECT 'system.impersonate'
UNION
SELECT 'organization.view'
UNION
SELECT 'organization.create'
UNION
SELECT 'user.create'
UNION
SELECT 'user.view'
UNION
SELECT 'user.edit'
UNION
SELECT 'user.delete'
UNION
SELECT 'user.manage_assignments'
UNION
SELECT 'role.assign_permissions'
UNION
SELECT 'project.create'
UNION
SELECT 'project.view'
UNION
SELECT 'project.edit'
UNION
SELECT 'project.delete'
UNION
SELECT 'contract.create'
UNION
SELECT 'contract.view'
UNION
SELECT 'contract.edit'
UNION
SELECT 'contract.delete'
UNION
SELECT 'master_data.view'
UNION
SELECT 'master_data.manage'
UNION
SELECT 'master_data.drawing_category.manage'
UNION
SELECT 'master_data.tag.manage'
UNION
SELECT 'document.view'
UNION
SELECT 'document.create'
UNION
SELECT 'document.edit'
UNION
SELECT 'document.delete'
UNION
SELECT 'correspondence.create'
UNION
SELECT 'rfa.create'
UNION
SELECT 'drawing.create'
UNION
SELECT 'drawing.view'
UNION
SELECT 'circulation.create'
UNION
SELECT 'circulation.respond'
UNION
SELECT 'workflow.action_review'
UNION
SELECT 'workflow.manage_definitions'
UNION
SELECT 'search.advanced'
UNION
SELECT 'json_schema.view'
UNION
SELECT 'json_schema.manage'
UNION
SELECT 'monitoring.manage_maintenance'
)
SELECT cp.permission_name,
CASE
WHEN p.permission_id IS NULL THEN '❌ MISSING'
ELSE '✅ EXISTS'
END AS STATUS,
p.permission_id
FROM code_permissions cp
LEFT JOIN permissions p ON cp.permission_name = p.permission_name
ORDER BY STATUS DESC,
cp.permission_name;
-- ==========================================================
-- 5. LIST PERMISSIONS FOR EACH ROLE
-- ==========================================================
SELECT r.role_name,
r.scope,
GROUP_CONCAT(
p.permission_name
ORDER BY p.permission_id SEPARATOR ', '
) AS permissions
FROM roles r
LEFT JOIN role_permissions rp ON r.role_id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.permission_id
GROUP BY r.role_id,
r.role_name,
r.scope
ORDER BY r.role_id;
-- ==========================================================
-- 6. CHECK SUPERADMIN HAS ALL PERMISSIONS
-- ==========================================================
SELECT 'Superadmin Permission Coverage' AS metric,
CONCAT(
COUNT(DISTINCT rp.permission_id),
' / ',
(
SELECT COUNT(*)
FROM permissions
WHERE is_active = 1
),
' (',
ROUND(
COUNT(DISTINCT rp.permission_id) * 100.0 / (
SELECT COUNT(*)
FROM permissions
WHERE is_active = 1
),
1
),
'%)'
) AS coverage
FROM role_permissions rp
WHERE rp.role_id = 1;
-- Superadmin
-- ==========================================================
-- 7. CHECK FOR DUPLICATE PERMISSIONS
-- ==========================================================
SELECT permission_name,
COUNT(*) AS duplicate_count
FROM permissions
GROUP BY permission_name
HAVING COUNT(*) > 1;
-- ==========================================================
-- 8. CHECK PERMISSIONS WITHOUT ROLE ASSIGNMENTS
-- ==========================================================
SELECT p.permission_id,
p.permission_name,
p.description
FROM permissions p
LEFT JOIN role_permissions rp ON p.permission_id = rp.permission_id
WHERE rp.permission_id IS NULL
AND p.is_active = 1
ORDER BY p.permission_id;
-- ==========================================================
-- 9. CHECK USER PERMISSION VIEW (v_user_all_permissions)
-- ==========================================================
-- Test with user_id = 1 (Superadmin)
SELECT 'User 1 (Superadmin) Permissions' AS metric,
COUNT(*) AS permission_count
FROM v_user_all_permissions
WHERE user_id = 1;
-- List first 10 permissions for user 1
SELECT user_id,
permission_name
FROM v_user_all_permissions
WHERE user_id = 1
ORDER BY permission_name
LIMIT 10;
-- ==========================================================
-- 10. CHECK SPECIFIC CRITICAL PERMISSIONS
-- ==========================================================
SELECT permission_name,
permission_id,
CASE
WHEN permission_id IS NOT NULL THEN '✅ Exists'
ELSE '❌ Missing'
END AS STATUS
FROM (
SELECT 'system.manage_all' AS permission_name
UNION
SELECT 'document.view'
UNION
SELECT 'user.create'
UNION
SELECT 'master_data.manage'
UNION
SELECT 'drawing.view'
UNION
SELECT 'workflow.action_review'
) required_perms
LEFT JOIN permissions p USING (permission_name)
ORDER BY permission_name;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,537 @@
# TASK-FE-012: Document Numbering Configuration UI
**ID:** TASK-FE-012
**Title:** Document Numbering Template Management UI
**Category:** Administration
**Priority:** P2 (Medium)
**Effort:** 3-4 days
**Dependencies:** TASK-FE-010, TASK-BE-004
**Assigned To:** Frontend Developer
---
## 📋 Overview
Build UI for configuring and managing document numbering templates including template builder, preview generator, and number sequence management.
---
## 🎯 Objectives
1. Create numbering template list and management
2. Build template editor with format preview
3. Implement template variable selector
4. Add numbering sequence viewer
5. Create template testing interface
6. Implement annual reset configuration
---
## ✅ Acceptance Criteria
- [x] List all numbering templates by document type
- [x] Create/edit templates with format preview
- [x] Template variables easily selectable
- [x] Preview shows example numbers
- [x] View current number sequences
- [x] Annual reset configurable
- [x] Validation prevents conflicts
---
## 🔧 Implementation Steps
### Step 1: Template List Page
```typescript
// File: src/app/(admin)/admin/numbering/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Eye } from 'lucide-react';
export default function NumberingPage() {
const [templates, setTemplates] = useState([]);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">
Document Numbering Configuration
</h1>
<p className="text-gray-600 mt-1">
Manage document numbering templates and sequences
</p>
</div>
<div className="flex gap-2">
<Select defaultValue="1">
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Project" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">LCBP3</SelectItem>
<SelectItem value="2">LCBP3-Maintenance</SelectItem>
</SelectContent>
</Select>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
</div>
<div className="grid gap-4">
{templates.map((template: any) => (
<Card key={template.template_id} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.document_type_name}
</h3>
<Badge>{template.discipline_code || 'All'}</Badge>
<Badge variant={template.is_active ? 'success' : 'secondary'}>
{template.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="bg-gray-100 rounded px-3 py-2 mb-3 font-mono text-sm">
{template.template_format}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Example: </span>
<span className="font-medium">
{template.example_number}
</span>
</div>
<div>
<span className="text-gray-600">Current Sequence: </span>
<span className="font-medium">
{template.current_number}
</span>
</div>
<div>
<span className="text-gray-600">Annual Reset: </span>
<span className="font-medium">
{template.reset_annually ? 'Yes' : 'No'}
</span>
</div>
<div>
<span className="text-gray-600">Padding: </span>
<span className="font-medium">
{template.padding_length} digits
</span>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View Sequences
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
```
### Step 2: Template Editor Component
```typescript
// File: src/components/numbering/template-editor.tsx
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
const VARIABLES = [
{ key: '{ORIGINATOR}', name: 'Originator Code', example: 'PAT' },
{ key: '{RECIPIENT}', name: 'Recipient Code', example: 'CN' },
{ key: '{CORR_TYPE}', name: 'Correspondence Type', example: 'RFA' },
{ key: '{SUB_TYPE}', name: 'Sub Type', example: '21' },
{ key: '{DISCIPLINE}', name: 'Discipline', example: 'STR' },
{ key: '{RFA_TYPE}', name: 'RFA Type', example: 'SDW' },
{ key: '{YEAR:B.E.}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR:A.D.}', name: 'Year (A.D.)', example: '2025' },
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
{ key: '{REV}', name: 'Revision', example: 'A' },
];
export function TemplateEditor({ template, onSave }: any) {
const [format, setFormat] = useState(template?.template_format || '');
const [preview, setPreview] = useState('');
useEffect(() => {
// Generate preview
let previewText = format;
VARIABLES.forEach((v) => {
previewText = previewText.replace(new RegExp(v.key, 'g'), v.example);
});
setPreview(previewText);
}, [format]);
const insertVariable = (variable: string) => {
setFormat((prev) => prev + variable);
};
return (
<Card className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Template Configuration</h3>
<div className="grid gap-4">
<div>
<Label>Document Type *</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="RFA">RFA</SelectItem>
<SelectItem value="RFI">RFI</SelectItem>
<SelectItem value="TRANSMITTAL">Transmittal</SelectItem>
<SelectItem value="LETTER">Letter</SelectItem>
<SelectItem value="MEMO">Memorandum</SelectItem>
<SelectItem value="EMAIL">Email</SelectItem>
<SelectItem value="MOM">Minutes of Meeting</SelectItem>
<SelectItem value="INSTRUCTION">Instruction</SelectItem>
<SelectItem value="NOTICE">Notice</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="All disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All</SelectItem>
<SelectItem value="STR">STR - Structure</SelectItem>
<SelectItem value="ARC">ARC - Architecture</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Template Format *</Label>
<div className="space-y-2">
<Input
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="e.g., {ORG}-{DOCTYPE}-{YYYY}-{SEQ}"
className="font-mono"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
<Button
key={v.key}
variant="outline"
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
>
{v.key}
</Button>
))}
</div>
</div>
</div>
<div>
<Label>Preview</Label>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">Example number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{preview || 'Enter format above'}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Sequence Padding Length</Label>
<Input type="number" defaultValue={4} min={1} max={10} />
<p className="text-xs text-gray-500 mt-1">
Number of digits (e.g., 4 = 0001, 0002)
</p>
</div>
<div>
<Label>Starting Number</Label>
<Input type="number" defaultValue={1} min={1} />
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2">
<Checkbox defaultChecked />
<span className="text-sm">Reset annually (on January 1st)</span>
</label>
</div>
</div>
</div>
{/* Variable Reference */}
<div>
<h4 className="font-semibold mb-3">Available Variables</h4>
<div className="grid grid-cols-2 gap-3">
{VARIABLES.map((v) => (
<div
key={v.key}
className="flex items-center justify-between p-2 bg-gray-50 rounded"
>
<div>
<Badge variant="outline" className="font-mono">
{v.key}
</Badge>
<p className="text-xs text-gray-600 mt-1">{v.name}</p>
</div>
<span className="text-sm text-gray-500">{v.example}</span>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button onClick={onSave}>Save Template</Button>
</div>
</Card>
);
}
```
### Step 3: Number Sequence Viewer
```typescript
// File: src/components/numbering/sequence-viewer.tsx
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { RefreshCw } from 'lucide-react';
export function SequenceViewer({ templateId }: { templateId: number }) {
const [sequences, setSequences] = useState([]);
const [search, setSearch] = useState('');
return (
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Number Sequences</h3>
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
<div className="mb-4">
<Input
placeholder="Search by year, organization..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2">
{sequences.map((seq: any) => (
<div
key={seq.sequence_id}
className="flex items-center justify-between p-3 bg-gray-50 rounded"
>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{seq.year}</span>
{seq.organization_code && (
<Badge>{seq.organization_code}</Badge>
)}
{seq.discipline_code && (
<Badge variant="outline">{seq.discipline_code}</Badge>
)}
</div>
<div className="text-sm text-gray-600">
Current: {seq.current_number} | Last Generated:{' '}
{seq.last_generated_number}
</div>
</div>
<div className="text-sm text-gray-500">
Updated {new Date(seq.updated_at).toLocaleDateString()}
</div>
</div>
))}
</div>
</Card>
);
}
```
### Step 4: Template Testing Dialog
```typescript
// File: src/components/numbering/template-tester.tsx
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
export function TemplateTester({ open, onOpenChange, template }: any) {
const [testData, setTestData] = useState({
organization_id: 1,
discipline_id: null,
year: new Date().getFullYear(),
});
const [generatedNumber, setGeneratedNumber] = useState('');
const handleTest = async () => {
// Call API to generate test number
const response = await fetch('/api/numbering/test', {
method: 'POST',
body: JSON.stringify({ template_id: template.template_id, ...testData }),
});
const result = await response.json();
setGeneratedNumber(result.number);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Test Number Generation</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Organization</Label>
<Select value={testData.organization_id.toString()}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Select discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">STR</SelectItem>
<SelectItem value="2">ARC</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleTest} className="w-full">
Generate Test Number
</Button>
{generatedNumber && (
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-gray-600 mb-1">Generated Number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{generatedNumber}
</p>
</Card>
)}
</div>
</DialogContent>
</Dialog>
);
}
```
---
## 📦 Deliverables
- [ ] Template list page
- [ ] Template editor with variable selector
- [ ] Live preview generator
- [ ] Number sequence viewer
- [ ] Template testing interface
- [ ] Annual reset configuration
- [ ] Validation rules
---
## 🧪 Testing
1. **Template Creation**
- Create template → Preview updates
- Insert variables → Format correct
- Save template → Persists
2. **Number Generation**
- Test template → Generates number
- Variables replaced correctly
- Sequence increments
3. **Sequence Management**
- View sequences → Shows all active sequences
- Search sequences → Filters correctly
---
## 🔗 Related Documents
- [TASK-BE-004: Document Numbering](./TASK-BE-004-document-numbering.md)
- [ADR-002: Document Numbering Strategy](../../05-decisions/ADR-002-document-numbering-strategy.md)
---
**Created:** 2025-12-01
**Status:** Ready