251205:0000 Just start debug backend/frontend

This commit is contained in:
2025-12-05 00:32:02 +07:00
parent dc8b80c5f9
commit 2865bebdb1
88 changed files with 6751 additions and 1016 deletions

View File

@@ -0,0 +1,18 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { config } from 'dotenv';
config();
export const databaseConfig: TypeOrmModuleOptions = {
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
username: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || 'Center#2025',
database: process.env.DB_DATABASE || 'lcbp3_dev',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../database/migrations/*{.ts,.js}'],
synchronize: false,
logging: process.env.NODE_ENV === 'development',
autoLoadEntities: true,
};

View File

@@ -0,0 +1,136 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1701234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Organizations
await queryRunner.query(`
CREATE TABLE organizations (
id INT PRIMARY KEY AUTO_INCREMENT,
organization_code VARCHAR(20) NOT NULL UNIQUE,
organization_name VARCHAR(200) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
INDEX idx_org_code (organization_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Permissions
await queryRunner.query(`
CREATE TABLE permissions (
id INT PRIMARY KEY AUTO_INCREMENT,
permission_code VARCHAR(50) NOT NULL UNIQUE,
description TEXT NULL,
resource VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Roles
await queryRunner.query(`
CREATE TABLE roles (
id INT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(50) NOT NULL UNIQUE,
description TEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Role Permissions
await queryRunner.query(`
CREATE TABLE role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Users
await queryRunner.query(`
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// User Roles
await queryRunner.query(`
CREATE TABLE user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Correspondences
await queryRunner.query(`
CREATE TABLE correspondences (
id INT PRIMARY KEY AUTO_INCREMENT,
document_number VARCHAR(50) NOT NULL UNIQUE,
subject VARCHAR(255) NOT NULL,
body TEXT NULL,
type VARCHAR(50) NOT NULL,
status VARCHAR(50) DEFAULT 'Draft',
created_by_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// RFAs
await queryRunner.query(`
CREATE TABLE rfas (
id INT PRIMARY KEY AUTO_INCREMENT,
rfa_number VARCHAR(50) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
discipline_code VARCHAR(20) NULL,
status VARCHAR(50) DEFAULT 'Draft',
created_by_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Drawings
await queryRunner.query(`
CREATE TABLE drawings (
id INT PRIMARY KEY AUTO_INCREMENT,
drawing_number VARCHAR(50) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
drawing_type VARCHAR(50) NOT NULL,
revision VARCHAR(10) NOT NULL,
status VARCHAR(50) DEFAULT 'Draft',
uploaded_by_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (uploaded_by_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS drawings`);
await queryRunner.query(`DROP TABLE IF EXISTS rfas`);
await queryRunner.query(`DROP TABLE IF EXISTS correspondences`);
await queryRunner.query(`DROP TABLE IF EXISTS user_roles`);
await queryRunner.query(`DROP TABLE IF EXISTS users`);
await queryRunner.query(`DROP TABLE IF EXISTS role_permissions`);
await queryRunner.query(`DROP TABLE IF EXISTS roles`);
await queryRunner.query(`DROP TABLE IF EXISTS permissions`);
await queryRunner.query(`DROP TABLE IF EXISTS organizations`);
}
}

View File

@@ -0,0 +1,72 @@
import { DataSource } from 'typeorm';
import { Organization } from '../../modules/organizations/entities/organization.entity';
export async function seedOrganizations(dataSource: DataSource) {
const repo = dataSource.getRepository(Organization);
const orgs = [
{ organizationCode: 'กทท.', organizationName: 'การท่าเรือแห่งประเทศไทย' },
{
organizationCode: 'สคฉ.3',
organizationName: 'โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3',
},
{
organizationCode: 'สคฉ.3-01',
organizationName: 'ตรวจรับพัสดุ ที่ปรึกษาควบคุมงาน',
},
{
organizationCode: 'สคฉ.3-02',
organizationName: 'ตรวจรับพัสดุ งานทางทะเล',
},
{
organizationCode: 'สคฉ.3-03',
organizationName: 'ตรวจรับพัสดุ อาคารและระบบสาธารณูปโภค',
},
{
organizationCode: 'สคฉ.3-04',
organizationName: 'ตรวจรับพัสดุ ตรวจสอบผลกระทบสิ่งแวดล้อม',
},
{
organizationCode: 'สคฉ.3-05',
organizationName: 'ตรวจรับพัสดุ เยียวยาการประมง',
},
{
organizationCode: 'สคฉ.3-06',
organizationName: 'ตรวจรับพัสดุ งานก่อสร้าง ส่วนที่ 3',
},
{
organizationCode: 'สคฉ.3-07',
organizationName: 'ตรวจรับพัสดุ งานก่อสร้าง ส่วนที่ 4',
},
{
organizationCode: 'สคฉ.3-xx',
organizationName: 'ตรวจรับพัสดุ ที่ปรึกษาออกแบบ ส่วนที่ 4',
},
{ organizationCode: 'TEAM', organizationName: 'Designer Consulting Ltd.' },
{
organizationCode: 'คคง.',
organizationName: 'Construction Supervision Ltd.',
},
{ organizationCode: 'ผรม.1', organizationName: 'Contractor งานทางทะเล' },
{ organizationCode: 'ผรม.2', organizationName: 'Contractor งานก่อสร้าง' },
{
organizationCode: 'ผรม.3',
organizationName: 'Contractor งานก่อสร้าง ส่วนที่ 3',
},
{
organizationCode: 'ผรม.4',
organizationName: 'Contractor งานก่อสร้าง ส่วนที่ 4',
},
{ organizationCode: 'EN', organizationName: 'Third Party Environment' },
{ organizationCode: 'CAR', organizationName: 'Third Party Fishery Care' },
];
for (const org of orgs) {
const exists = await repo.findOneBy({
organizationCode: org.organizationCode,
});
if (!exists) {
await repo.save(repo.create(org));
}
}
}

View File

@@ -1,67 +1,24 @@
import { config } from 'dotenv';
import { DataSource } from 'typeorm';
import { seedWorkflowDefinitions } from '../seeds/workflow-definitions.seed'; // Import ฟังก์ชัน Seed ที่คุณมี
// Import Entities ที่เกี่ยวข้อง
import { WorkflowDefinition } from '../../modules/workflow-engine/entities/workflow-definition.entity';
import { WorkflowHistory } from '../../modules/workflow-engine/entities/workflow-history.entity';
import { WorkflowInstance } from '../../modules/workflow-engine/entities/workflow-instance.entity';
import { databaseConfig } from '../../config/database.config';
import { seedOrganizations } from './organization.seed';
import { seedUsers } from './user.seed';
// โหลด Environment Variables (.env)
config();
const runSeed = async () => {
// ตั้งค่าการเชื่อมต่อฐานข้อมูล (ควรตรงกับ docker-compose หรือ .env ของคุณ)
const dataSource = new DataSource({
type: 'mariadb',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
username: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || 'Center#2025',
database: process.env.DB_DATABASE || 'lcbp3_dev',
// สำคัญ: ต้องใส่ Entities ที่เกี่ยวข้องทั้งหมดเพื่อให้ TypeORM รู้จัก
entities: [
WorkflowDefinition,
WorkflowInstance,
WorkflowHistory,
// ใส่ Entity อื่นๆ ถ้าจำเป็น หรือใช้ path pattern: __dirname + '/../../modules/**/*.entity{.ts,.js}'
],
synchronize: false, // ห้ามใช้ true บน Production
});
async function runSeeds() {
const dataSource = new DataSource(databaseConfig as any);
await dataSource.initialize();
try {
console.log('🔌 Connecting to database...');
await dataSource.initialize();
console.log('✅ Database connected.');
console.log('🌱 Seeding database...');
console.log('🌱 Running Seeds...');
await seedWorkflowDefinitions(dataSource);
console.log('✅ Seeding completed successfully.');
await seedOrganizations(dataSource);
await seedUsers(dataSource);
console.log('✅ Seeding completed!');
} catch (error) {
console.error('❌ Error during seeding:', error);
console.error('❌ Seeding failed:', error);
} finally {
if (dataSource.isInitialized) {
await dataSource.destroy();
console.log('🔌 Database connection closed.');
}
await dataSource.destroy();
}
};
runSeed();
/*
npx ts-node -r tsconfig-paths/register src/database/run-seed.ts
**หรือเพิ่มใน `package.json` (แนะนำ):**
คุณสามารถเพิ่ม script ใน `package.json` เพื่อให้เรียกใช้ได้ง่ายขึ้นในอนาคต:
"scripts": {
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
}
http://googleusercontent.com/immersive_entry_chip/1
### 💡 ข้อควรระวัง
1. **Environment Variables:** ตรวจสอบให้แน่ใจว่าค่า Config (Host, User, Password) ในไฟล์ `run-seed.ts` หรือ `.env` นั้นถูกต้องและตรงกับ Docker Container ที่กำลังรันอยู่
2. **Entities:** หากฟังก์ชัน Seed มีการเรียกใช้ Entity อื่นนอกเหนือจาก `WorkflowDefinition` ต้องนำมาใส่ใน `entities: [...]` ของ `DataSource` ให้ครบ ไม่อย่างนั้นจะเจอ Error `RepositoryNotFoundError`
*/
runSeeds();

View File

@@ -0,0 +1,106 @@
import { DataSource } from 'typeorm';
import { User } from '../../modules/users/entities/user.entity';
import { Role } from '../../modules/auth/entities/role.entity';
import * as bcrypt from 'bcrypt';
export async function seedUsers(dataSource: DataSource) {
const userRepo = dataSource.getRepository(User);
const roleRepo = dataSource.getRepository(Role);
// Create Roles
const rolesData = [
{
roleName: 'Superadmin',
description:
'ผู้ดูแลระบบสูงสุด: สามารถทำทุกอย่างในระบบ, จัดการองค์กร, และจัดการข้อมูลหลักระดับ Global',
},
{
roleName: 'Org Admin',
description:
'ผู้ดูแลองค์กร: จัดการผู้ใช้ในองค์กร, จัดการบทบาท / สิทธิ์ภายในองค์กร, และดูรายงานขององค์กร',
},
{
roleName: 'Document Control',
description:
'ควบคุมเอกสารขององค์กร: เพิ่ม / แก้ไข / ลบเอกสาร, และกำหนดสิทธิ์เอกสารภายในองค์กร',
},
{
roleName: 'Editor',
description:
'ผู้แก้ไขเอกสารขององค์กร: เพิ่ม / แก้ไขเอกสารที่ได้รับมอบหมาย',
},
{
roleName: 'Viewer',
description: 'ผู้ดูเอกสารขององค์กร: ดูเอกสารที่มีสิทธิ์เข้าถึงเท่านั้น',
},
{
roleName: 'Project Manager',
description:
'ผู้จัดการโครงการ: จัดการสมาชิกในโครงการ, สร้าง / จัดการสัญญาในโครงการ, และดูรายงานโครงการ',
},
{
roleName: 'Contract Admin',
description:
'ผู้ดูแลสัญญา: จัดการสมาชิกในสัญญา, สร้าง / จัดการข้อมูลหลักเฉพาะสัญญา, และอนุมัติเอกสารในสัญญา',
},
];
const roleMap = new Map();
for (const r of rolesData) {
let role = await roleRepo.findOneBy({ roleName: r.roleName });
if (!role) {
role = await roleRepo.save(roleRepo.create(r));
}
roleMap.set(r.roleName, role);
}
// Create Users
const usersData = [
{
username: 'superadmin',
email: 'superadmin@example.com',
firstName: 'Super',
lastName: 'Admin',
roleName: 'Superadmin',
},
{
username: 'admin',
email: 'admin@example.com',
firstName: 'Admin',
lastName: 'คคง.',
roleName: 'Org Admin',
},
{
username: 'editor01',
email: 'editor01@example.com',
firstName: 'DC',
lastName: 'C1',
roleName: 'Editor',
},
{
username: 'viewer01',
email: 'viewer01@example.com',
firstName: 'Viewer',
lastName: 'สคฉ.03',
roleName: 'Viewer',
},
];
const salt = await bcrypt.genSalt();
const passwordHash = await bcrypt.hash('password123', salt); // Default password
for (const u of usersData) {
const exists = await userRepo.findOneBy({ username: u.username });
if (!exists) {
const user = userRepo.create({
username: u.username,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
passwordHash,
roles: [roleMap.get(u.roleName)],
});
await userRepo.save(user);
}
}
}

View File

@@ -0,0 +1,45 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToMany,
JoinTable,
} from 'typeorm';
@Entity('permissions')
export class Permission {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'permission_code', length: 50, unique: true })
permissionCode!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description!: string;
@Column({ name: 'resource', length: 50 })
resource!: string;
@Column({ name: 'action', length: 50 })
action!: string;
}
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'role_name', length: 50, unique: true })
roleName!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description!: string;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permissions',
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
})
permissions!: Permission[];
}

View File

@@ -0,0 +1,36 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('correspondences')
export class Correspondence {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'document_number', length: 50, unique: true })
documentNumber!: string;
@Column({ length: 255 })
subject!: string;
@Column({ type: 'text', nullable: true })
body!: string;
@Column({ length: 50 })
type!: string;
@Column({ length: 50, default: 'Draft' })
status!: string;
@Column({ name: 'created_by_id' })
createdById!: number;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by_id' })
createdBy!: User;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,44 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('drawings')
export class Drawing {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'drawing_number', length: 50, unique: true })
drawingNumber!: string;
@Column({ length: 255 })
title!: string;
@Column({ name: 'drawing_type', length: 50 })
drawingType!: string;
@Column({ length: 10 })
revision!: string;
@Column({ length: 50, default: 'Draft' })
status!: string;
@Column({ name: 'uploaded_by_id' })
uploadedById!: number;
@ManyToOne(() => User)
@JoinColumn({ name: 'uploaded_by_id' })
uploadedBy!: User;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,34 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';
@Entity('organizations')
export class Organization {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'organization_code', length: 20, unique: true })
@Index('idx_org_code')
organizationCode!: string;
@Column({ name: 'organization_name', length: 200 })
organizationName!: string;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt!: Date;
}

View File

@@ -0,0 +1,41 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('rfas')
export class Rfa {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'rfa_number', length: 50, unique: true })
rfaNumber!: string;
@Column({ length: 255 })
title!: string;
@Column({ name: 'discipline_code', length: 20, nullable: true })
disciplineCode!: string;
@Column({ length: 50, default: 'Draft' })
status!: string;
@Column({ name: 'created_by_id' })
createdById!: number;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by_id' })
createdBy!: User;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,52 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
ManyToMany,
JoinTable,
} from 'typeorm';
import { Role } from '../../auth/entities/role.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id!: number;
@Column({ length: 50, unique: true })
username!: string;
@Column({ length: 100, unique: true })
email!: string;
@Column({ name: 'password_hash', length: 255 })
passwordHash!: string;
@Column({ name: 'first_name', length: 100 })
firstName!: string;
@Column({ name: 'last_name', length: 100 })
lastName!: string;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@ManyToMany(() => Role)
@JoinTable({
name: 'user_roles',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
})
roles!: Role[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt!: Date;
}

View File

@@ -0,0 +1,122 @@
"use client";
import { useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { formatDistanceToNow } from "date-fns";
import { AuditLog } from "@/types/admin";
import { adminApi } from "@/lib/api/admin";
import { Loader2 } from "lucide-react";
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [filters, setFilters] = useState({
user: "",
action: "",
entity: "",
});
useEffect(() => {
const fetchLogs = async () => {
setLoading(true);
try {
const data = await adminApi.getAuditLogs();
setLogs(data);
} catch (error) {
console.error("Failed to fetch audit logs", error);
} finally {
setLoading(false);
}
};
fetchLogs();
}, []);
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">Audit Logs</h1>
<p className="text-muted-foreground mt-1">View system activity and changes</p>
</div>
{/* Filters */}
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<Input placeholder="Search user..." />
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CREATE">Create</SelectItem>
<SelectItem value="UPDATE">Update</SelectItem>
<SelectItem value="DELETE">Delete</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Entity Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
{/* Logs List */}
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-2">
{logs.map((log) => (
<Card key={log.audit_log_id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{log.user_name}</span>
<Badge variant={log.action === "CREATE" ? "default" : "secondary"}>
{log.action}
</Badge>
<Badge variant="outline">{log.entity_type}</Badge>
</div>
<p className="text-sm text-muted-foreground">{log.description}</p>
<p className="text-xs text-muted-foreground mt-2">
{formatDistanceToNow(new Date(log.created_at), {
addSuffix: true,
})}
</p>
</div>
{log.ip_address && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
IP: {log.ip_address}
</span>
)}
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState, useEffect } from "react";
import { TemplateEditor } from "@/components/numbering/template-editor";
import { SequenceViewer } from "@/components/numbering/sequence-viewer";
import { numberingApi } from "@/lib/api/numbering";
import { CreateTemplateDto } from "@/types/numbering";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function EditTemplatePage({ params }: { params: { id: string } }) {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [initialData, setInitialData] = useState<Partial<CreateTemplateDto> | null>(null);
useEffect(() => {
const fetchTemplate = async () => {
setLoading(true);
try {
const data = await numberingApi.getTemplate(parseInt(params.id));
if (data) {
setInitialData({
document_type_id: data.document_type_id,
discipline_code: data.discipline_code,
template_format: data.template_format,
reset_annually: data.reset_annually,
padding_length: data.padding_length,
starting_number: 1, // Default for edit view as we don't usually reset this
});
}
} catch (error) {
console.error("Failed to fetch template", error);
} finally {
setLoading(false);
}
};
fetchTemplate();
}, [params.id]);
const handleSave = async (data: CreateTemplateDto) => {
try {
await numberingApi.updateTemplate(parseInt(params.id), data);
router.push("/admin/numbering");
} catch (error) {
console.error("Failed to update template", error);
alert("Failed to update template");
}
};
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="p-6 space-y-6">
<h1 className="text-3xl font-bold">Edit Numbering Template</h1>
<Tabs defaultValue="config">
<TabsList>
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="sequences">Sequences</TabsTrigger>
</TabsList>
<TabsContent value="config" className="mt-4">
{initialData && (
<TemplateEditor initialData={initialData} onSave={handleSave} />
)}
</TabsContent>
<TabsContent value="sequences" className="mt-4">
<SequenceViewer templateId={parseInt(params.id)} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { TemplateEditor } from "@/components/numbering/template-editor";
import { numberingApi } from "@/lib/api/numbering";
import { CreateTemplateDto } from "@/types/numbering";
import { useRouter } from "next/navigation";
export default function NewTemplatePage() {
const router = useRouter();
const handleSave = async (data: CreateTemplateDto) => {
try {
await numberingApi.createTemplate(data);
router.push("/admin/numbering");
} catch (error) {
console.error("Failed to create template", error);
alert("Failed to create template");
}
};
return (
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold">New Numbering Template</h1>
<TemplateEditor onSave={handleSave} />
</div>
);
}

View File

@@ -0,0 +1,136 @@
"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";
export default function NumberingPage() {
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [testerOpen, setTesterOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<NumberingTemplate | null>(null);
useEffect(() => {
const fetchTemplates = async () => {
setLoading(true);
try {
const data = await numberingApi.getTemplates();
setTemplates(data);
} catch (error) {
console.error("Failed to fetch templates", error);
} finally {
setLoading(false);
}
};
fetchTemplates();
}, []);
const handleTest = (template: NumberingTemplate) => {
setSelectedTemplate(template);
setTesterOpen(true);
};
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-muted-foreground mt-1">
Manage document 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>
{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="bg-muted 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-muted-foreground">Example: </span>
<span className="font-medium">
{template.example_number}
</span>
</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>
)}
<TemplateTester
open={testerOpen}
onOpenChange={setTesterOpen}
template={selectedTemplate}
/>
</div>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/common/data-table";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Organization } from "@/types/admin";
import { adminApi } from "@/lib/api/admin";
import { ColumnDef } from "@tanstack/react-table";
import { Loader2, Plus } from "lucide-react";
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
org_code: "",
org_name: "",
org_name_th: "",
description: "",
});
const fetchOrgs = async () => {
setLoading(true);
try {
const data = await adminApi.getOrganizations();
setOrganizations(data);
} catch (error) {
console.error("Failed to fetch organizations", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOrgs();
}, []);
const columns: ColumnDef<Organization>[] = [
{ accessorKey: "org_code", header: "Code" },
{ accessorKey: "org_name", header: "Name (EN)" },
{ accessorKey: "org_name_th", header: "Name (TH)" },
{ accessorKey: "description", header: "Description" },
];
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await adminApi.createOrganization(formData);
setDialogOpen(false);
setFormData({
org_code: "",
org_name: "",
org_name_th: "",
description: "",
});
fetchOrgs();
} catch (error) {
console.error(error);
alert("Failed to create organization");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Organizations</h1>
<p className="text-muted-foreground mt-1">Manage project organizations</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Organization
</Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<DataTable columns={columns} data={organizations} />
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Organization</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="org_code">Organization Code *</Label>
<Input
id="org_code"
value={formData.org_code}
onChange={(e) =>
setFormData({ ...formData, org_code: e.target.value })
}
placeholder="e.g., PAT"
/>
</div>
<div>
<Label htmlFor="org_name">Name (English) *</Label>
<Input
id="org_name"
value={formData.org_name}
onChange={(e) =>
setFormData({ ...formData, org_name: e.target.value })
}
/>
</div>
<div>
<Label htmlFor="org_name_th">Name (Thai)</Label>
<Input
id="org_name_th"
value={formData.org_name_th}
onChange={(e) =>
setFormData({ ...formData, org_name_th: e.target.value })
}
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/common/data-table";
import { UserDialog } from "@/components/admin/user-dialog";
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Plus, Loader2 } from "lucide-react";
import { User } from "@/types/admin";
import { adminApi } from "@/lib/api/admin";
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const fetchUsers = async () => {
setLoading(true);
try {
const data = await adminApi.getUsers();
setUsers(data);
} catch (error) {
console.error("Failed to fetch users", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const columns: ColumnDef<User>[] = [
{
accessorKey: "username",
header: "Username",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "first_name",
header: "Name",
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`,
},
{
accessorKey: "is_active",
header: "Status",
cell: ({ row }) => (
<Badge variant={row.original.is_active ? "default" : "secondary"} className={row.original.is_active ? "bg-green-600 hover:bg-green-700" : ""}>
{row.original.is_active ? "Active" : "Inactive"}
</Badge>
),
},
{
id: "roles",
header: "Roles",
cell: ({ row }) => (
<div className="flex gap-1 flex-wrap">
{row.original.roles?.map((role) => (
<Badge key={role.role_id} variant="outline">
{role.role_name}
</Badge>
))}
</div>
),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedUser(row.original);
setDialogOpen(true);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => alert("Deactivate not implemented in mock")}
>
{row.original.is_active ? "Deactivate" : "Activate"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground mt-1">
Manage system users and their roles
</p>
</div>
<Button
onClick={() => {
setSelectedUser(null);
setDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<DataTable columns={columns} data={users} />
)}
<UserDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
user={selectedUser}
onSuccess={fetchUsers}
/>
</div>
);
}

View File

@@ -0,0 +1,164 @@
"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";
export default function WorkflowEditPage({ params }: { params: { id: string } }) {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [workflowData, setWorkflowData] = useState({
workflow_name: "",
description: "",
workflow_type: "CORRESPONDENCE" as WorkflowType,
dsl_definition: "",
});
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]);
const handleSave = async () => {
setSaving(true);
try {
await workflowApi.updateWorkflow(parseInt(params.id), workflowData);
router.push("/admin/workflows");
} catch (error) {
console.error("Failed to save workflow", error);
alert("Failed to save workflow");
} finally {
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="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Edit Workflow</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>Cancel</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save 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>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={workflowData.description}
onChange={(e) =>
setWorkflowData({
...workflowData,
description: e.target.value,
})
}
/>
</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>
</Card>
<Tabs defaultValue="dsl">
<TabsList>
<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="visual" className="mt-4">
<VisualWorkflowBuilder />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState } 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";
export default function NewWorkflowPage() {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [workflowData, setWorkflowData] = useState({
workflow_name: "",
description: "",
workflow_type: "CORRESPONDENCE" as WorkflowType,
dsl_definition: "name: New Workflow\nsteps: []",
});
const handleSave = async () => {
setSaving(true);
try {
await workflowApi.createWorkflow(workflowData);
router.push("/admin/workflows");
} catch (error) {
console.error("Failed to create workflow", error);
alert("Failed to create workflow");
} finally {
setSaving(false);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">New Workflow</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>Cancel</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
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,
})
}
placeholder="e.g., Special RFA Approval"
/>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
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>
</Card>
<Tabs defaultValue="dsl">
<TabsList>
<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="visual" className="mt-4">
<VisualWorkflowBuilder />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"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, Copy, Trash, Loader2 } from "lucide-react";
import Link from "next/link";
import { Workflow } from "@/types/workflow";
import { workflowApi } from "@/lib/api/workflows";
export default function WorkflowsPage() {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchWorkflows = async () => {
setLoading(true);
try {
const data = await workflowApi.getWorkflows();
setWorkflows(data);
} catch (error) {
console.error("Failed to fetch workflows", error);
} finally {
setLoading(false);
}
};
fetchWorkflows();
}, []);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Workflow Configuration</h1>
<p className="text-muted-foreground mt-1">
Manage workflow definitions and routing rules
</p>
</div>
<Link href="/admin/workflows/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Workflow
</Button>
</Link>
</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">
{workflows.map((workflow) => (
<Card key={workflow.workflow_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">
{workflow.workflow_name}
</h3>
<Badge variant={workflow.is_active ? "default" : "secondary"} className={workflow.is_active ? "bg-green-600 hover:bg-green-700" : ""}>
{workflow.is_active ? "Active" : "Inactive"}
</Badge>
<Badge variant="outline">v{workflow.version}</Badge>
</div>
<p className="text-sm text-muted-foreground mb-3">
{workflow.description}
</p>
<div className="flex gap-6 text-sm text-muted-foreground">
<span>Type: {workflow.workflow_type}</span>
<span>Steps: {workflow.step_count}</span>
<span>
Updated:{" "}
{new Date(workflow.updated_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/admin/workflows/${workflow.workflow_id}/edit`}>
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => alert("Clone functionality mocked")}>
<Copy className="mr-2 h-4 w-4" />
Clone
</Button>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive" onClick={() => alert("Delete functionality mocked")}>
<Trash className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { AdminSidebar } from "@/components/admin/sidebar";
import { redirect } from "next/navigation";
// import { getServerSession } from "next-auth"; // Commented out for now as we are mocking auth
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
// Mock Admin Check
// const session = await getServerSession();
// if (!session?.user?.roles?.some((r) => r.role_name === 'ADMIN')) {
// redirect('/');
// }
// For development, we assume user is admin
const isAdmin = true;
if (!isAdmin) {
redirect("/");
}
return (
<div className="flex h-[calc(100vh-4rem)]"> {/* Subtract header height */}
<AdminSidebar />
<div className="flex-1 overflow-auto bg-muted/10">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { correspondenceApi } from "@/lib/api/correspondences";
import { CorrespondenceDetail } from "@/components/correspondences/detail";
import { notFound } from "next/navigation";
export default async function CorrespondenceDetailPage({
params,
}: {
params: { id: string };
}) {
const id = parseInt(params.id);
if (isNaN(id)) {
notFound();
}
const correspondence = await correspondenceApi.getById(id);
if (!correspondence) {
notFound();
}
return <CorrespondenceDetail data={correspondence} />;
}

View File

@@ -1,355 +1,18 @@
// File: app/(dashboard)/correspondences/new/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { format } from "date-fns";
import { CalendarIcon, ChevronLeft, Save, Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { FileUpload } from "@/components/forms/file-upload";
import { cn } from "@/lib/utils";
// --- Schema Definition ---
const correspondenceSchema = z.object({
projectId: z.string().min(1, "กรุณาเลือกโครงการ"),
originatorId: z.string().min(1, "กรุณาเลือกองค์กรผู้ส่ง"),
type: z.string().min(1, "กรุณาเลือกประเภทเอกสาร"),
discipline: z.string().optional(), // สำหรับ RFA/Letter ที่มีสาขา
subType: z.string().optional(), // สำหรับแบ่งประเภทย่อย
recipientId: z.string().min(1, "กรุณาเลือกผู้รับ (To)"),
subject: z.string().min(5, "หัวข้อต้องยาวอย่างน้อย 5 ตัวอักษร"),
message: z.string().optional(),
replyRequiredBy: z.date().optional(),
});
type FormValues = z.infer<typeof correspondenceSchema>;
// --- Mock Data for Dropdowns ---
const projects = [
{ id: "1", code: "LCBP3-C1", name: "งานก่อสร้างงานทางทะเล (ส่วนที่ 1)" },
{ id: "2", code: "LCBP3-C2", name: "งานก่อสร้างอาคาร (ส่วนที่ 2)" },
];
const organizations = [
{ id: "1", code: "PAT", name: "การท่าเรือฯ (Owner)" },
{ id: "2", code: "CSC", name: "ที่ปรึกษาคุมงาน (Consult)" },
{ id: "3", code: "CNNC", name: "ผู้รับเหมา C1" },
];
const docTypes = [
{ id: "LET", name: "Letter (จดหมาย)" },
{ id: "MEM", name: "Memo (บันทึกข้อความ)" },
{ id: "RFA", name: "RFA (ขออนุมัติ)" },
{ id: "RFI", name: "RFI (ขอข้อมูล)" },
];
const disciplines = [
{ id: "GEN", name: "General (ทั่วไป)" },
{ id: "STR", name: "Structural (โครงสร้าง)" },
{ id: "ARC", name: "Architectural (สถาปัตยกรรม)" },
{ id: "MEP", name: "MEP (งานระบบ)" },
];
export default function CreateCorrespondencePage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<File[]>([]);
// React Hook Form
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(correspondenceSchema),
defaultValues: {
originatorId: "3", // Default เป็น Org ของ User (Mock: CNNC)
},
});
// Watch values to update dynamic parts
const selectedProject = watch("projectId");
const selectedType = watch("type");
const selectedDiscipline = watch("discipline");
// Logic จำลองการ Preview เลขที่เอกสาร (Document Numbering)
const getPreviewNumber = () => {
if (!selectedProject || !selectedType) return "---";
const proj = projects.find(p => p.id === selectedProject)?.code || "PROJ";
const type = selectedType;
const disc = selectedDiscipline ? `-${selectedDiscipline}` : "";
return `${proj}-${type}${disc}-0001 (Draft)`;
};
const onSubmit = async (data: FormValues) => {
setIsLoading(true);
try {
console.log("Form Data:", data);
console.log("Files:", files);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
alert("บันทึกเอกสารเรียบร้อยแล้ว");
router.push("/correspondences");
} catch (error) {
console.error(error);
alert("เกิดข้อผิดพลาดในการบันทึก");
} finally {
setIsLoading(false);
}
};
import { CorrespondenceForm } from "@/components/correspondences/form";
export default function NewCorrespondencePage() {
return (
<div className="max-w-4xl mx-auto space-y-6 pb-10">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<div>
<h2 className="text-2xl font-bold tracking-tight">Create Correspondence</h2>
<p className="text-muted-foreground">
</p>
</div>
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">New Correspondence</h1>
<p className="text-muted-foreground mt-1">
Create a new official letter or communication record.
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Section 1: Basic Info */}
<Card>
<CardHeader>
<CardTitle> (Basic Information)</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
{/* Project Select */}
<div className="space-y-2">
<Label className="after:content-['*'] after:text-red-500"> (Project)</Label>
<Select onValueChange={(val) => setValue("projectId", val)}>
<SelectTrigger className={errors.projectId ? "border-destructive" : ""}>
<SelectValue placeholder="เลือกโครงการ..." />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
{errors.projectId && <p className="text-xs text-destructive">{errors.projectId.message}</p>}
</div>
{/* Document Type Select */}
<div className="space-y-2">
<Label className="after:content-['*'] after:text-red-500"> (Type)</Label>
<Select onValueChange={(val) => setValue("type", val)}>
<SelectTrigger className={errors.type ? "border-destructive" : ""}>
<SelectValue placeholder="เลือกประเภท..." />
</SelectTrigger>
<SelectContent>
{docTypes.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
{errors.type && <p className="text-xs text-destructive">{errors.type.message}</p>}
</div>
{/* Discipline (Conditional) */}
<div className="space-y-2">
<Label> (Discipline)</Label>
<Select onValueChange={(val) => setValue("discipline", val)}>
<SelectTrigger>
<SelectValue placeholder="ระบุสาขา (ถ้ามี)..." />
</SelectTrigger>
<SelectContent>
{disciplines.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground"> RFA/RFI </p>
</div>
{/* Originator (Sender) */}
<div className="space-y-2">
<Label className="after:content-['*'] after:text-red-500"> (From)</Label>
<Select defaultValue="3" onValueChange={(val) => setValue("originatorId", val)} disabled>
<SelectTrigger>
<SelectValue placeholder="Select Originator" />
</SelectTrigger>
<SelectContent>
{organizations.map((o) => (
<SelectItem key={o.id} value={o.id}>{o.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Document Number Preview */}
<div className="md:col-span-2 p-4 bg-muted/30 rounded-lg border border-dashed flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Preview Document No:</span>
<span className="font-mono text-lg font-bold text-primary">{getPreviewNumber()}</span>
</div>
</CardContent>
</Card>
{/* Section 2: Details & Recipients */}
<Card>
<CardHeader>
<CardTitle> (Details & Recipients)</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Recipient */}
<div className="space-y-2">
<Label className="after:content-['*'] after:text-red-500"> (To)</Label>
<Select onValueChange={(val) => setValue("recipientId", val)}>
<SelectTrigger className={errors.recipientId ? "border-destructive" : ""}>
<SelectValue placeholder="เลือกหน่วยงานผู้รับ..." />
</SelectTrigger>
<SelectContent>
{organizations.filter(o => o.id !== "3").map((o) => (
<SelectItem key={o.id} value={o.id}>{o.name}</SelectItem>
))}
</SelectContent>
</Select>
{errors.recipientId && <p className="text-xs text-destructive">{errors.recipientId.message}</p>}
</div>
{/* Subject */}
<div className="space-y-2">
<Label className="after:content-['*'] after:text-red-500"> (Subject)</Label>
<Input
placeholder="ระบุหัวข้อเอกสาร..."
className={errors.subject ? "border-destructive" : ""}
{...register("subject")}
/>
{errors.subject && <p className="text-xs text-destructive">{errors.subject.message}</p>}
</div>
{/* Message/Body */}
<div className="space-y-2">
<Label> (Message)</Label>
<Textarea
placeholder="พิมพ์รายละเอียดเพิ่มเติม..."
className="min-h-[120px]"
{...register("message")}
/>
</div>
{/* Reply Required Date */}
<div className="flex flex-col space-y-2">
<Label> (Reply Required By)</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!watch("replyRequiredBy") && "text-muted-foreground"
)}
>
{watch("replyRequiredBy") ? (
format(watch("replyRequiredBy")!, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={watch("replyRequiredBy")}
onSelect={(date) => setValue("replyRequiredBy", date)}
disabled={(date) => date < new Date()}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</CardContent>
</Card>
{/* Section 3: Attachments */}
<Card>
<CardHeader>
<CardTitle> (Attachments)</CardTitle>
<CardDescription>
PDF, DWG, Office ( 50MB )
</CardDescription>
</CardHeader>
<CardContent>
<FileUpload
onFilesChange={setFiles}
maxFiles={10}
accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.zip,.jpg,.png"
/>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button variant="outline" type="button" onClick={() => router.back()} disabled={isLoading}>
</Button>
<Button type="submit" disabled={isLoading} className="min-w-[120px]">
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" /> Save as Draft
</>
)}
</Button>
<Button type="submit" disabled={isLoading} className="min-w-[120px] bg-green-600 hover:bg-green-700">
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Submit
</Button>
</div>
</form>
<div className="bg-card border rounded-lg p-6 shadow-sm">
<CorrespondenceForm />
</div>
</div>
);
}
}

View File

@@ -1,290 +1,50 @@
// File: app/(dashboard)/correspondences/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Plus,
Search,
Filter,
MoreHorizontal,
FileText,
Calendar,
Eye
} from "lucide-react";
import { CorrespondenceList } from "@/components/correspondences/list";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import { Plus } from "lucide-react";
import { correspondenceApi } from "@/lib/api/correspondences";
import { Pagination } from "@/components/common/pagination";
// --- Type Definitions ---
type Correspondence = {
id: string;
documentNumber: string;
subject: string;
type: "Letter" | "Memo" | "RFI" | "Transmittal";
status: "Draft" | "Submitted" | "Approved" | "Rejected";
project: string;
date: string;
from: string;
to: string;
};
// --- Mock Data ---
const mockData: Correspondence[] = [
{
id: "1",
documentNumber: "LCBP3-LET-GEN-001",
subject: "Submission of Monthly Progress Report - January 2025",
type: "Letter",
status: "Submitted",
project: "LCBP3-C1",
date: "2025-01-05",
from: "CNNC",
to: "PAT",
},
{
id: "2",
documentNumber: "LCBP3-RFI-STR-024",
subject: "Clarification on Beam Reinforcement Detail at Zone A",
type: "RFI",
status: "Approved",
project: "LCBP3-C2",
date: "2025-01-10",
from: "ITD-NWR",
to: "TEAM",
},
{
id: "3",
documentNumber: "LCBP3-MEMO-HR-005",
subject: "Site Access Protocol Update",
type: "Memo",
status: "Draft",
project: "LCBP3",
date: "2025-01-12",
from: "PAT",
to: "All Contractors",
},
{
id: "4",
documentNumber: "LCBP3-TRN-STR-011",
subject: "Transmittal of Shop Drawings for Piling Works",
type: "Transmittal",
status: "Submitted",
project: "LCBP3-C1",
date: "2025-01-15",
from: "CNNC",
to: "CSC",
},
];
export default function CorrespondencesPage() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState("ALL");
// Filter Logic
const filteredData = mockData.filter((item) => {
const matchesSearch =
item.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.documentNumber.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === "ALL" || item.type === typeFilter;
return matchesSearch && matchesType;
export default async function CorrespondencesPage({
searchParams,
}: {
searchParams: { page?: string; status?: string; search?: string };
}) {
const page = parseInt(searchParams.page || "1");
const data = await correspondenceApi.getAll({
page,
status: searchParams.status,
search: searchParams.search,
});
const getStatusVariant = (status: string) => {
switch (status) {
case "Approved": return "success";
case "Rejected": return "destructive";
case "Draft": return "secondary";
default: return "default"; // Submitted
}
};
const handleCreate = () => {
router.push("/correspondences/new");
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Correspondences</h2>
<p className="text-muted-foreground">
-
<h1 className="text-3xl font-bold">Correspondences</h1>
<p className="text-muted-foreground mt-1">
Manage official letters and communications
</p>
</div>
<Button onClick={handleCreate} className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* Toolbar (Search & Filter) */}
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="ค้นหาจากเลขที่เอกสาร หรือ หัวข้อ..."
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Select defaultValue="ALL" onValueChange={setTypeFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="ประเภทเอกสาร" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL"></SelectItem>
<SelectItem value="Letter">Letter</SelectItem>
<SelectItem value="Memo">Memo</SelectItem>
<SelectItem value="RFI">RFI</SelectItem>
<SelectItem value="Transmittal">Transmittal</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
<Link href="/correspondences/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Correspondence
</Button>
</div>
</Link>
</div>
{/* Desktop View: Table */}
<div className="hidden rounded-md border bg-card md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox />
</TableHead>
<TableHead>Document No.</TableHead>
<TableHead className="w-[400px]">Subject</TableHead>
<TableHead>Type</TableHead>
<TableHead>From/To</TableHead>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => router.push(`/correspondences/${item.id}`)}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox />
</TableCell>
<TableCell className="font-medium text-primary">
{item.documentNumber}
<div className="text-xs text-muted-foreground mt-0.5">{item.project}</div>
</TableCell>
<TableCell>
<span className="line-clamp-2">{item.subject}</span>
</TableCell>
<TableCell>{item.type}</TableCell>
<TableCell>
<div className="text-xs">
<div className="text-muted-foreground">From: <span className="text-foreground font-medium">{item.from}</span></div>
<div className="text-muted-foreground">To: <span className="text-foreground font-medium">{item.to}</span></div>
</div>
</TableCell>
<TableCell>{item.date}</TableCell>
<TableCell>
<Badge variant={getStatusVariant(item.status)}>{item.status}</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); router.push(`/correspondences/${item.id}`); }}>
View Details
</DropdownMenuItem>
<DropdownMenuItem>Download PDF</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Create Transmittal</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Filters component could go here */}
{/* Mobile View: Cards */}
<div className="grid gap-4 md:hidden">
{filteredData.map((item) => (
<Card key={item.id} onClick={() => router.push(`/correspondences/${item.id}`)}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="flex flex-col">
<Badge variant="outline" className="w-fit mb-2">{item.type}</Badge>
<CardTitle className="text-base font-bold">{item.documentNumber}</CardTitle>
</div>
<Badge variant={getStatusVariant(item.status)}>{item.status}</Badge>
</div>
</CardHeader>
<CardContent className="pb-2 text-sm">
<p className="line-clamp-2 mb-3">{item.subject}</p>
<div className="grid grid-cols-2 gap-2 text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" /> {item.project}
</div>
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> {item.date}
</div>
</div>
<div className="mt-3 pt-3 border-t grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground block">From</span>
<span className="font-medium">{item.from}</span>
</div>
<div>
<span className="text-muted-foreground block">To</span>
<span className="font-medium">{item.to}</span>
</div>
</div>
</CardContent>
<CardFooter className="pt-2">
<Button variant="ghost" size="sm" className="w-full text-primary">
<Eye className="mr-2 h-4 w-4" /> View Details
</Button>
</CardFooter>
</Card>
))}
<CorrespondenceList data={data} />
<div className="mt-4">
<Pagination
currentPage={data.page}
totalPages={data.totalPages}
total={data.total}
/>
</div>
</div>
);
}
}

View File

@@ -1,67 +1,39 @@
// File: app/(dashboard)/dashboard/page.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StatsCards } from "@/components/dashboard/stats-cards";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { PendingTasks } from "@/components/dashboard/pending-tasks";
import { QuickActions } from "@/components/dashboard/quick-actions";
import { dashboardApi } from "@/lib/api/dashboard";
export default async function DashboardPage() {
// Fetch data in parallel
const [stats, activities, tasks] = await Promise.all([
dashboardApi.getStats(),
dashboardApi.getRecentActivity(),
dashboardApi.getPendingTasks(),
]);
export default function DashboardPage() {
return (
<div className="flex flex-col gap-4">
{/* Header Section */}
<div className="flex items-center">
<h1 className="text-lg font-semibold md:text-2xl">Dashboard</h1>
</div>
{/* KPI Cards Section */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">12</div>
<p className="text-xs text-muted-foreground">
+2
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
RFAs
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">24</div>
<p className="text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
{/* Add more KPI cards as needed */}
<div className="space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground mt-1">
Welcome back! Here's an overview of your project status.
</p>
</div>
<QuickActions />
</div>
{/* Main Content Section (My Tasks + Recent Activity) */}
<div className="grid gap-4 md:gap-8 lg:grid-cols-3">
{/* Content Area หลัก (My Tasks) กินพื้นที่ 2 ส่วน */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle> (My Tasks)</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full rounded bg-muted/20 flex items-center justify-center text-muted-foreground">
Table Placeholder (My Tasks)
</div>
</CardContent>
</Card>
<StatsCards stats={stats} />
{/* Recent Activity กินพื้นที่ 1 ส่วนด้านขวา */}
<RecentActivity />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<RecentActivity activities={activities} />
</div>
<div className="lg:col-span-1">
<PendingTasks tasks={tasks} />
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,112 @@
import { drawingApi } from "@/lib/api/drawings";
import { notFound } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Download, FileText, GitCompare } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { RevisionHistory } from "@/components/drawings/revision-history";
import { format } from "date-fns";
export default async function DrawingDetailPage({
params,
}: {
params: { id: string };
}) {
const id = parseInt(params.id);
if (isNaN(id)) {
notFound();
}
const drawing = await drawingApi.getById(id);
if (!drawing) {
notFound();
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/drawings">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{drawing.drawing_number}</h1>
<p className="text-muted-foreground">
{drawing.title}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Download Current
</Button>
{drawing.revision_count > 1 && (
<Button variant="outline">
<GitCompare className="mr-2 h-4 w-4" />
Compare Revisions
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Info */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl">Drawing Details</CardTitle>
<Badge>{drawing.type}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-muted-foreground">Discipline</p>
<p className="font-medium mt-1">{drawing.discipline?.discipline_name} ({drawing.discipline?.discipline_code})</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Sheet Number</p>
<p className="font-medium mt-1">{drawing.sheet_number}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Scale</p>
<p className="font-medium mt-1">{drawing.scale || "N/A"}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Latest Issue Date</p>
<p className="font-medium mt-1">{format(new Date(drawing.issue_date), "dd MMM yyyy")}</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold mb-3">Preview</h3>
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center border-2 border-dashed">
<div className="text-center">
<FileText className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
<p className="text-muted-foreground">PDF Preview Placeholder</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Revisions */}
<div className="space-y-6">
<RevisionHistory revisions={drawing.revisions || []} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DrawingList } from "@/components/drawings/list";
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
import Link from "next/link";
export default function DrawingsPage() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Drawings</h1>
<p className="text-muted-foreground mt-1">
Manage contract and shop drawings
</p>
</div>
<Link href="/drawings/upload">
<Button>
<Upload className="mr-2 h-4 w-4" />
Upload Drawing
</Button>
</Link>
</div>
<Tabs defaultValue="contract" className="w-full">
<TabsList className="grid w-full grid-cols-2 max-w-[400px]">
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
</TabsList>
<TabsContent value="contract" className="mt-6">
<DrawingList type="CONTRACT" />
</TabsContent>
<TabsContent value="shop" className="mt-6">
<DrawingList type="SHOP" />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { DrawingUploadForm } from "@/components/drawings/upload-form";
export default function DrawingUploadPage() {
return (
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">Upload Drawing</h1>
<p className="text-muted-foreground mt-1">
Upload a new contract or shop drawing revision.
</p>
</div>
<DrawingUploadForm />
</div>
);
}

View File

@@ -1,7 +1,5 @@
// File: app/(dashboard)/layout.tsx
import { Header } from "@/components/layout/header";
import { Sidebar } from "@/components/layout/sidebar";
import { Navbar } from "@/components/layout/navbar";
import { DashboardShell } from "@/components/layout/dashboard-shell"; // Import Wrapper
export default function DashboardLayout({
children,
@@ -9,17 +7,14 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
return (
<div className="relative min-h-screen bg-muted/10">
{/* Sidebar (Fixed Position) */}
<div className="flex min-h-screen bg-background">
<Sidebar />
{/* Main Content (Dynamic Margin) */}
<DashboardShell>
<Navbar />
<main className="flex-1 p-4 lg:p-6 overflow-x-hidden">
<div className="flex-1 flex flex-col min-h-screen overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6 bg-muted/10">
{children}
</main>
</DashboardShell>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,22 @@
import { rfaApi } from "@/lib/api/rfas";
import { RFADetail } from "@/components/rfas/detail";
import { notFound } from "next/navigation";
export default async function RFADetailPage({
params,
}: {
params: { id: string };
}) {
const id = parseInt(params.id);
if (isNaN(id)) {
notFound();
}
const rfa = await rfaApi.getById(id);
if (!rfa) {
notFound();
}
return <RFADetail data={rfa} />;
}

View File

@@ -0,0 +1,16 @@
import { RFAForm } from "@/components/rfas/form";
export default function NewRFAPage() {
return (
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">New RFA</h1>
<p className="text-muted-foreground mt-1">
Create a new Request for Approval.
</p>
</div>
<RFAForm />
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { RFAList } from "@/components/rfas/list";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Plus } from "lucide-react";
import { rfaApi } from "@/lib/api/rfas";
import { Pagination } from "@/components/common/pagination";
export default async function RFAsPage({
searchParams,
}: {
searchParams: { page?: string; status?: string; search?: string };
}) {
const page = parseInt(searchParams.page || "1");
const data = await rfaApi.getAll({
page,
status: searchParams.status,
search: searchParams.search,
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">RFAs (Request for Approval)</h1>
<p className="text-muted-foreground mt-1">
Manage approval requests and submissions
</p>
</div>
<Link href="/rfas/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New RFA
</Button>
</Link>
</div>
<RFAList data={data} />
<div className="mt-4">
<Pagination
currentPage={data.page}
totalPages={data.totalPages}
total={data.total}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { SearchFilters } from "@/components/search/filters";
import { SearchResults } from "@/components/search/results";
import { searchApi } from "@/lib/api/search";
import { SearchResult, SearchFilters as FilterType } from "@/types/search";
export default function SearchPage() {
const searchParams = useSearchParams();
const query = searchParams.get("q") || "";
const [results, setResults] = useState<SearchResult[]>([]);
const [filters, setFilters] = useState<FilterType>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchResults = async () => {
setLoading(true);
try {
const data = await searchApi.search({ query, ...filters });
setResults(data);
} catch (error) {
console.error("Search failed", error);
} finally {
setLoading(false);
}
};
fetchResults();
}, [query, filters]);
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-muted-foreground mt-1">
{loading
? "Searching..."
: `Found ${results.length} results for "${query}"`
}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<SearchFilters onFilterChange={setFilters} />
</div>
<div className="lg:col-span-3">
<SearchResults results={results} query={query} loading={loading} />
</div>
</div>
</div>
);
}

View File

@@ -1,85 +1,147 @@
// File: app/demo/page.tsx
"use client";
import { ResponsiveDataTable, ColumnDef } from "@/components/custom/responsive-data-table";
import { FileUploadZone, FileWithMeta } from "@/components/custom/file-upload-zone";
import { WorkflowVisualizer, WorkflowStep } from "@/components/custom/workflow-visualizer";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { DataTable } from "@/components/common/data-table";
import { FileUpload } from "@/components/common/file-upload";
import { StatusBadge } from "@/components/common/status-badge";
import { ConfirmDialog } from "@/components/common/confirm-dialog";
import { Pagination } from "@/components/common/pagination";
import { Button } from "@/components/ui/button";
import { ColumnDef } from "@tanstack/react-table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// --- Mock Data ---
interface DocItem {
// Mock Data for Table
interface Payment {
id: string;
title: string;
amount: number;
status: string;
date: string;
email: string;
}
const mockData: DocItem[] = [
{ id: "RFA-001", title: "แบบก่อสร้างฐานราก", status: "Approved", date: "2023-11-01" },
{ id: "RFA-002", title: "วัสดุงานผนัง", status: "Pending", date: "2023-11-05" },
];
const columns: ColumnDef<DocItem>[] = [
{ key: "id", header: "Document No." },
{ key: "title", header: "Subject" },
{
key: "status",
const columns: ColumnDef<Payment>[] = [
{
accessorKey: "status",
header: "Status",
cell: (item) => (
<Badge variant={item.status === 'Approved' ? 'default' : 'secondary'}>
{item.status}
</Badge>
)
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "amount",
header: "Amount",
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return <div className="font-medium">{formatted}</div>;
},
},
{ key: "date", header: "Date" },
];
const mockSteps: WorkflowStep[] = [
{ id: 1, label: "ผู้รับเหมา", subLabel: "Submit", status: "completed", date: "10/11/2023" },
{ id: 2, label: "CSC", subLabel: "Review", status: "current" },
{ id: 3, label: "Designer", subLabel: "Approve", status: "pending" },
{ id: 4, label: "Owner", subLabel: "Acknowledge", status: "pending" },
const data: Payment[] = [
{ id: "1", amount: 100, status: "PENDING", email: "m@example.com" },
{ id: "2", amount: 200, status: "APPROVED", email: "test@example.com" },
{ id: "3", amount: 300, status: "REJECTED", email: "fail@example.com" },
{ id: "4", amount: 400, status: "IN_REVIEW", email: "review@example.com" },
{ id: "5", amount: 500, status: "DRAFT", email: "draft@example.com" },
];
export default function DemoPage() {
const [files, setFiles] = useState<File[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [page, setPage] = useState(1);
return (
<div className="container mx-auto py-10 space-y-10">
<section className="space-y-4">
<h2 className="text-2xl font-bold">1. Responsive Data Table</h2>
<ResponsiveDataTable
data={mockData}
columns={columns}
keyExtractor={(i) => i.id}
renderMobileCard={(item) => (
<div className="border p-4 rounded-lg shadow-sm bg-card">
<div className="flex justify-between items-center mb-2">
<span className="font-bold">{item.id}</span>
<Badge>{item.status}</Badge>
</div>
<p className="text-sm mb-2">{item.title}</p>
<div className="text-xs text-muted-foreground text-right">{item.date}</div>
<Button size="sm" className="w-full mt-2" variant="outline">View Detail</Button>
</div>
)}
/>
</section>
<div className="p-8 space-y-8">
<h1 className="text-3xl font-bold">Common Components Demo</h1>
<section className="space-y-4">
<h2 className="text-2xl font-bold">2. File Upload Zone</h2>
<FileUploadZone
onFilesChanged={(files) => console.log("Files:", files)}
accept={[".pdf", ".jpg", ".png"]}
/>
</section>
{/* Status Badges */}
<Card>
<CardHeader>
<CardTitle>Status Badges</CardTitle>
</CardHeader>
<CardContent className="flex gap-4 flex-wrap">
<StatusBadge status="DRAFT" />
<StatusBadge status="PENDING" />
<StatusBadge status="IN_REVIEW" />
<StatusBadge status="APPROVED" />
<StatusBadge status="REJECTED" />
<StatusBadge status="CLOSED" />
<StatusBadge status="UNKNOWN" />
</CardContent>
</Card>
<section className="space-y-4">
<h2 className="text-2xl font-bold">3. Workflow Visualizer</h2>
<div className="border p-6 rounded-lg">
<WorkflowVisualizer steps={mockSteps} />
</div>
</section>
{/* File Upload */}
<Card>
<CardHeader>
<CardTitle>File Upload</CardTitle>
</CardHeader>
<CardContent>
<FileUpload
onFilesSelected={(files) => setFiles(files)}
maxFiles={3}
/>
<div className="mt-4">
<h3 className="font-semibold">Selected Files:</h3>
<ul className="list-disc pl-5">
{files.map((f, i) => (
<li key={i}>
{f.name} ({(f.size / 1024).toFixed(2)} KB)
</li>
))}
</ul>
</div>
</CardContent>
</Card>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Data Table</CardTitle>
</CardHeader>
<CardContent>
<DataTable columns={columns} data={data} />
</CardContent>
</Card>
{/* Pagination */}
<Card>
<CardHeader>
<CardTitle>Pagination</CardTitle>
</CardHeader>
<CardContent>
<Pagination
currentPage={page}
totalPages={10}
total={100}
/>
{/* Note: In a real app, clicking pagination would update 'page' via URL or state */}
</CardContent>
</Card>
{/* Confirm Dialog */}
<Card>
<CardHeader>
<CardTitle>Confirmation Dialog</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={() => setDialogOpen(true)}>Open Dialog</Button>
<ConfirmDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title="Are you sure?"
description="This action cannot be undone. This will permanently delete your account and remove your data from our servers."
onConfirm={() => {
alert("Confirmed!");
setDialogOpen(false);
}}
/>
</CardContent>
</Card>
</div>
);
}
}

View File

@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Users, Building2, Settings, FileText, Activity, Network, Hash } from "lucide-react";
const menuItems = [
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/workflows", label: "Workflows", icon: Network },
{ href: "/admin/numbering", label: "Numbering", icon: Hash },
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
{ href: "/admin/projects", label: "Projects", icon: FileText },
{ href: "/admin/settings", label: "Settings", icon: Settings },
{ href: "/admin/audit-logs", label: "Audit Logs", icon: Activity },
];
export function AdminSidebar() {
const pathname = usePathname();
return (
<aside className="w-64 border-r bg-muted/20 p-4 hidden md:block h-full">
<h2 className="text-lg font-bold mb-6 px-3">Admin Panel</h2>
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium",
isActive
? "bg-primary text-primary-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { User, CreateUserDto } from "@/types/admin";
import { useEffect, useState } from "react";
import { adminApi } from "@/lib/api/admin";
import { Loader2 } from "lucide-react";
const userSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
password: z.string().min(6, "Password must be at least 6 characters").optional(),
is_active: z.boolean().default(true),
roles: z.array(z.number()).min(1, "At least one role is required"),
});
type UserFormData = z.infer<typeof userSchema>;
interface UserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User | null;
onSuccess: () => void;
}
export function UserDialog({ open, onOpenChange, user, onSuccess }: UserDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
is_active: true,
roles: [],
},
});
useEffect(() => {
if (user) {
reset({
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
is_active: user.is_active,
roles: user.roles.map((r) => r.role_id),
});
} else {
reset({
username: "",
email: "",
first_name: "",
last_name: "",
is_active: true,
roles: [],
});
}
}, [user, reset, open]);
const availableRoles = [
{ role_id: 1, role_name: "ADMIN", description: "System Administrator" },
{ role_id: 2, role_name: "USER", description: "Regular User" },
{ role_id: 3, role_name: "APPROVER", description: "Document Approver" },
];
const selectedRoles = watch("roles") || [];
const handleRoleChange = (roleId: number, checked: boolean) => {
const currentRoles = selectedRoles;
const newRoles = checked
? [...currentRoles, roleId]
: currentRoles.filter((id) => id !== roleId);
setValue("roles", newRoles, { shouldValidate: true });
};
const onSubmit = async (data: UserFormData) => {
setIsSubmitting(true);
try {
if (user) {
// await adminApi.updateUser(user.user_id, data);
console.log("Update user", user.user_id, data);
} else {
await adminApi.createUser(data as CreateUserDto);
}
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
alert("Failed to save user");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{user ? "Edit User" : "Create New User"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="username">Username *</Label>
<Input id="username" {...register("username")} disabled={!!user} />
{errors.username && (
<p className="text-sm text-destructive mt-1">
{errors.username.message}
</p>
)}
</div>
<div>
<Label htmlFor="email">Email *</Label>
<Input id="email" type="email" {...register("email")} />
{errors.email && (
<p className="text-sm text-destructive mt-1">
{errors.email.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="first_name">First Name *</Label>
<Input id="first_name" {...register("first_name")} />
{errors.first_name && (
<p className="text-sm text-destructive mt-1">
{errors.first_name.message}
</p>
)}
</div>
<div>
<Label htmlFor="last_name">Last Name *</Label>
<Input id="last_name" {...register("last_name")} />
{errors.last_name && (
<p className="text-sm text-destructive mt-1">
{errors.last_name.message}
</p>
)}
</div>
</div>
{!user && (
<div>
<Label htmlFor="password">Password *</Label>
<Input id="password" type="password" {...register("password")} />
{errors.password && (
<p className="text-sm text-destructive mt-1">
{errors.password.message}
</p>
)}
</div>
)}
<div>
<Label className="mb-3 block">Roles *</Label>
<div className="space-y-2 border rounded-md p-4">
{availableRoles.map((role) => (
<div
key={role.role_id}
className="flex items-start gap-3"
>
<Checkbox
id={`role-${role.role_id}`}
checked={selectedRoles.includes(role.role_id)}
onCheckedChange={(checked) => handleRoleChange(role.role_id, checked as boolean)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor={`role-${role.role_id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{role.role_name}
</Label>
<p className="text-sm text-muted-foreground">
{role.description}
</p>
</div>
</div>
))}
</div>
{errors.roles && (
<p className="text-sm text-destructive mt-1">
{errors.roles.message}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Checkbox
id="is_active"
checked={watch("is_active")}
onCheckedChange={(checked) => setValue("is_active", checked as boolean)}
/>
<Label htmlFor="is_active">Active</Label>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{user ? "Update User" : "Create User"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

View File

@@ -0,0 +1,49 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
onConfirm: () => void;
confirmText?: string;
cancelText?: string;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
onConfirm,
confirmText = "Confirm",
cancelText = "Cancel",
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getSortedRowModel,
SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
sorting,
},
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, X, File } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
maxFiles?: number;
accept?: string;
maxSize?: number; // bytes
}
export function FileUpload({
onFilesSelected,
maxFiles = 5,
accept = ".pdf,.doc,.docx",
maxSize = 10485760, // 10MB
}: FileUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const newFiles = [...prev, ...acceptedFiles].slice(0, maxFiles);
onFilesSelected(newFiles);
return newFiles;
});
},
[maxFiles, onFilesSelected]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxFiles,
accept: accept.split(",").reduce((acc, ext) => ({ ...acc, [ext]: [] }), {}),
maxSize,
});
const removeFile = (index: number) => {
setFiles((prev) => {
const newFiles = prev.filter((_, i) => i !== index);
onFilesSelected(newFiles);
return newFiles;
});
};
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
isDragActive
? "border-primary bg-primary/5"
: "border-gray-300 hover:border-gray-400"
)}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">
{isDragActive
? "Drop files here"
: "Drag & drop files or click to browse"}
</p>
<p className="mt-1 text-xs text-gray-500">
Maximum {maxFiles} files, {(maxSize / 1024 / 1024).toFixed(0)}MB each
</p>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<File className="h-5 w-5 text-gray-500" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
interface PaginationProps {
currentPage: number;
totalPages: number;
total: number;
}
export function Pagination({
currentPage,
totalPages,
total,
}: PaginationProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createPageURL = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
return (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing page {currentPage} of {totalPages} ({total} total items)
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage - 1))}
disabled={currentPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
{/* Simple pagination logic: show max 5 pages */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
// Logic to center current page could be added here for large page counts
// For now, just showing first 5 or all if < 5
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={pageNum === currentPage ? "default" : "outline"}
size="sm"
onClick={() => router.push(createPageURL(pageNum))}
>
{pageNum}
</Button>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => router.push(createPageURL(currentPage + 1))}
disabled={currentPage >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface StatusBadgeProps {
status: string;
className?: string;
}
const statusConfig: Record<string, { label: string; variant: string }> = {
DRAFT: { label: "Draft", variant: "secondary" },
PENDING: { label: "Pending", variant: "warning" }, // Note: Shadcn/UI might not have 'warning' variant by default, may need custom CSS or use 'secondary'
IN_REVIEW: { label: "In Review", variant: "default" }, // Using 'default' (primary) for In Review
APPROVED: { label: "Approved", variant: "success" }, // Note: 'success' might need custom CSS
REJECTED: { label: "Rejected", variant: "destructive" },
CLOSED: { label: "Closed", variant: "outline" },
};
// Fallback for unknown statuses
const defaultStatus = { label: "Unknown", variant: "outline" };
export function StatusBadge({ status, className }: StatusBadgeProps) {
const config = statusConfig[status] || { label: status, variant: "default" };
// Mapping custom variants to Shadcn Badge variants if needed
// For now, we'll assume standard variants or rely on className overrides for colors
let badgeVariant: "default" | "secondary" | "destructive" | "outline" = "default";
let customClass = "";
switch (config.variant) {
case "secondary":
badgeVariant = "secondary";
break;
case "destructive":
badgeVariant = "destructive";
break;
case "outline":
badgeVariant = "outline";
break;
case "warning":
badgeVariant = "secondary"; // Fallback
customClass = "bg-yellow-500 hover:bg-yellow-600 text-white";
break;
case "success":
badgeVariant = "default"; // Fallback
customClass = "bg-green-500 hover:bg-green-600 text-white";
break;
case "info":
badgeVariant = "default";
customClass = "bg-blue-500 hover:bg-blue-600 text-white";
break;
default:
badgeVariant = "default";
}
return (
<Badge
variant={badgeVariant}
className={cn("uppercase", customClass, className)}
>
{config.label}
</Badge>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { Correspondence } 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 Link from "next/link";
import { Separator } from "@/components/ui/separator";
interface CorrespondenceDetailProps {
data: Correspondence;
}
export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
return (
<div className="space-y-6">
{/* Header / Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/correspondences">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{data.document_number}</h1>
<p className="text-muted-foreground">
Created on {format(new Date(data.created_at), "dd MMM yyyy HH:mm")}
</p>
</div>
</div>
<div className="flex gap-2">
{/* Workflow Actions Placeholder */}
{data.status === "DRAFT" && (
<Button>Submit for Review</Button>
)}
{data.status === "IN_REVIEW" && (
<>
<Button variant="destructive">Reject</Button>
<Button className="bg-green-600 hover:bg-green-700">Approve</Button>
</>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl">{data.subject}</CardTitle>
<StatusBadge status={data.status} />
</div>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-gray-700 whitespace-pre-wrap">
{data.description || "No description provided."}
</p>
</div>
<Separator />
<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) => (
<div
key={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>
</div>
<Button variant="ghost" size="sm">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic">No attachments.</p>
)}
</div>
</CardContent>
</Card>
</div>
{/* Sidebar Info */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Importance</p>
<div className="mt-1">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${data.importance === 'URGENT' ? 'bg-red-100 text-red-800' :
data.importance === 'HIGH' ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'}`}>
{data.importance}
</span>
</div>
</div>
<Separator />
<div>
<p className="text-sm font-medium text-muted-foreground">From Organization</p>
<p className="font-medium mt-1">{data.from_organization?.org_name}</p>
<p className="text-xs text-muted-foreground">{data.from_organization?.org_code}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">To Organization</p>
<p className="font-medium mt-1">{data.to_organization?.org_name}</p>
<p className="text-xs text-muted-foreground">{data.to_organization?.org_code}</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FileUpload } from "@/components/common/file-upload";
import { useRouter } from "next/navigation";
import { correspondenceApi } from "@/lib/api/correspondences";
import { useState } from "react";
import { Loader2 } from "lucide-react";
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
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"),
attachments: z.array(z.instanceof(File)).optional(),
});
type FormData = z.infer<typeof correspondenceSchema>;
export function CorrespondenceForm() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(correspondenceSchema),
defaultValues: {
importance: "NORMAL",
document_type_id: 1,
},
});
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
await correspondenceApi.create(data as any); // Type casting for mock
router.push("/correspondences");
router.refresh();
} catch (error) {
console.error(error);
alert("Failed to create correspondence");
} finally {
setIsSubmitting(false);
}
};
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" />
{errors.subject && (
<p className="text-sm text-destructive">{errors.subject.message}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register("description")}
rows={4}
placeholder="Enter description details..."
/>
</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>
<Select
onValueChange={(v) => setValue("from_organization_id", parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select Organization" />
</SelectTrigger>
<SelectContent>
{/* Mock Data - In real app, fetch from API */}
<SelectItem value="1">Contractor A (CON-A)</SelectItem>
<SelectItem value="2">Owner (OWN)</SelectItem>
<SelectItem value="3">Consultant (CNS)</SelectItem>
</SelectContent>
</Select>
{errors.from_organization_id && (
<p className="text-sm text-destructive">{errors.from_organization_id.message}</p>
)}
</div>
<div className="space-y-2">
<Label>To Organization *</Label>
<Select
onValueChange={(v) => setValue("to_organization_id", parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select Organization" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Contractor A (CON-A)</SelectItem>
<SelectItem value="2">Owner (OWN)</SelectItem>
<SelectItem value="3">Consultant (CNS)</SelectItem>
</SelectContent>
</Select>
{errors.to_organization_id && (
<p className="text-sm text-destructive">{errors.to_organization_id.message}</p>
)}
</div>
</div>
{/* Importance */}
<div className="space-y-2">
<Label>Importance</Label>
<div className="flex gap-6 mt-2">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
value="NORMAL"
{...register("importance")}
className="accent-primary"
/>
<span>Normal</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
value="HIGH"
{...register("importance")}
className="accent-primary"
/>
<span>High</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
value="URGENT"
{...register("importance")}
className="accent-primary"
/>
<span>Urgent</span>
</label>
</div>
</div>
{/* File Attachments */}
<div className="space-y-2">
<Label>Attachments</Label>
<FileUpload
onFilesSelected={(files) => setValue("attachments", files)}
maxFiles={10}
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-4 pt-6 border-t">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Correspondence
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { Correspondence } from "@/types/correspondence";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button";
import { Eye, Edit } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
interface CorrespondenceListProps {
data: {
items: Correspondence[];
total: number;
page: number;
totalPages: number;
};
}
export function CorrespondenceList({ data }: CorrespondenceListProps) {
const columns: ColumnDef<Correspondence>[] = [
{
accessorKey: "document_number",
header: "Document No.",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("document_number")}</span>
),
},
{
accessorKey: "subject",
header: "Subject",
cell: ({ row }) => (
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
{row.getValue("subject")}
</div>
),
},
{
accessorKey: "from_organization.org_name",
header: "From",
},
{
accessorKey: "to_organization.org_name",
header: "To",
},
{
accessorKey: "created_at",
header: "Date",
cell: ({ row }) => format(new Date(row.getValue("created_at")), "dd MMM yyyy"),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
},
{
id: "actions",
cell: ({ row }) => {
const item = row.original;
return (
<div className="flex gap-2">
<Link href={`/correspondences/${item.correspondence_id}`}>
<Button variant="ghost" size="icon" title="View">
<Eye className="h-4 w-4" />
</Button>
</Link>
{item.status === "DRAFT" && (
<Link href={`/correspondences/${item.correspondence_id}/edit`}>
<Button variant="ghost" size="icon" title="Edit">
<Edit className="h-4 w-4" />
</Button>
</Link>
)}
</div>
);
},
},
];
return (
<div>
<DataTable columns={columns} data={data.items} />
{/* Pagination component would go here, receiving props from data */}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { PendingTask } from "@/types/dashboard";
import { AlertCircle, ArrowRight } from "lucide-react";
interface PendingTasksProps {
tasks: PendingTask[];
}
export function PendingTasks({ tasks }: PendingTasksProps) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
Pending Tasks
{tasks.length > 0 && (
<Badge variant="destructive" className="rounded-full h-5 w-5 p-0 flex items-center justify-center text-[10px]">
{tasks.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No pending tasks. Good job!
</p>
) : (
tasks.map((task) => (
<Link
key={task.id}
href={task.url}
className="block p-3 bg-muted/40 rounded-lg border hover:bg-muted/60 transition-colors group"
>
<div className="flex items-start justify-between mb-1">
<span className="text-sm font-medium group-hover:text-primary transition-colors">
{task.title}
</span>
{task.daysOverdue > 0 ? (
<Badge variant="destructive" className="text-[10px] h-5 px-1.5">
{task.daysOverdue}d overdue
</Badge>
) : (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 bg-yellow-50 text-yellow-700 border-yellow-200">
Due Soon
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-1 mb-2">
{task.description}
</p>
<div className="flex items-center text-xs text-primary font-medium">
View Details <ArrowRight className="ml-1 h-3 w-3" />
</div>
</Link>
))
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { Button } from "@/components/ui/button";
import { PlusCircle, Upload, FileText } from "lucide-react";
import Link from "next/link";
export function QuickActions() {
return (
<div className="flex gap-3 overflow-x-auto pb-2">
<Link href="/rfas/new">
<Button className="bg-blue-600 hover:bg-blue-700">
<PlusCircle className="mr-2 h-4 w-4" />
New RFA
</Button>
</Link>
<Link href="/correspondences/new">
<Button variant="outline">
<FileText className="mr-2 h-4 w-4" />
New Correspondence
</Button>
</Link>
<Link href="/drawings/upload">
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
Upload Drawing
</Button>
</Link>
</div>
);
}

View File

@@ -1,87 +1,55 @@
// File: components/dashboard/recent-activity.tsx
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { formatDistanceToNow } from "date-fns";
import { ActivityLog } from "@/types/dashboard";
import Link from "next/link";
// Type จำลองตามโครงสร้าง v_audit_log_details
type AuditLogItem = {
audit_id: number;
username: string;
email: string;
action: string;
entity_type: string;
entity_id: string;
created_at: string;
avatar?: string;
};
interface RecentActivityProps {
activities: ActivityLog[];
}
// Mock Data
const recentActivities: AuditLogItem[] = [
{
audit_id: 1,
username: "Editor01",
email: "editor01@example.com",
action: "rfa.create",
entity_type: "RFA",
entity_id: "LCBP3-RFA-STR-001",
created_at: "2025-11-26T09:00:00Z",
},
{
audit_id: 2,
username: "Superadmin",
email: "admin@example.com",
action: "user.create",
entity_type: "User",
entity_id: "new_user_01",
created_at: "2025-11-26T10:30:00Z",
},
{
audit_id: 3,
username: "Viewer01",
email: "viewer01@example.com",
action: "document.view",
entity_type: "Correspondence",
entity_id: "LCBP3-LET-GEN-005",
created_at: "2025-11-26T11:15:00Z",
},
{
audit_id: 4,
username: "Editor01",
email: "editor01@example.com",
action: "shop_drawing.upload",
entity_type: "Shop Drawing",
entity_id: "SHP-STR-COL-01",
created_at: "2025-11-26T13:45:00Z",
},
];
export function RecentActivity() {
export function RecentActivity({ activities }: RecentActivityProps) {
return (
<Card className="col-span-3 lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardTitle className="text-lg">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-8">
{recentActivities.map((item) => (
<div key={item.audit_id} className="flex items-center">
<Avatar className="h-9 w-9">
<AvatarImage src={item.avatar} alt={item.username} />
<AvatarFallback>{item.username[0] + item.username[1]}</AvatarFallback>
<div className="space-y-6">
{activities.map((activity) => (
<div
key={activity.id}
className="flex gap-4 pb-4 border-b last:border-0 last:pb-0"
>
<Avatar className="h-10 w-10 border">
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{activity.user.initials}
</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">
{item.username}
</p>
<p className="text-sm text-muted-foreground">
{formatActionMessage(item)}
</p>
</div>
<div className="ml-auto text-xs text-muted-foreground">
{new Date(item.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{activity.user.name}</span>
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 font-normal">
{activity.action}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
})}
</span>
</div>
<Link href={activity.targetUrl} className="block group">
<p className="text-sm text-foreground group-hover:text-primary transition-colors">
{activity.description}
</p>
</Link>
</div>
</div>
))}
@@ -90,19 +58,3 @@ export function RecentActivity() {
</Card>
);
}
function formatActionMessage(item: AuditLogItem) {
// Simple formatter for demo. In real app, use translation or mapping.
switch (item.action) {
case "rfa.create":
return `Created RFA ${item.entity_id}`;
case "user.create":
return `Created new user ${item.entity_id}`;
case "document.view":
return `Viewed document ${item.entity_id}`;
case "shop_drawing.upload":
return `Uploaded drawing ${item.entity_id}`;
default:
return `Performed ${item.action}`;
}
}

View File

@@ -0,0 +1,64 @@
"use client";
import { Card } from "@/components/ui/card";
import { FileText, Clipboard, CheckCircle, Clock } from "lucide-react";
import { DashboardStats } from "@/types/dashboard";
interface StatsCardsProps {
stats: DashboardStats;
}
export function StatsCards({ stats }: StatsCardsProps) {
const cards = [
{
title: "Total Correspondences",
value: stats.correspondences,
icon: FileText,
color: "text-blue-600",
bgColor: "bg-blue-50",
},
{
title: "Active RFAs",
value: stats.rfas,
icon: Clipboard,
color: "text-purple-600",
bgColor: "bg-purple-50",
},
{
title: "Approved Documents",
value: stats.approved,
icon: CheckCircle,
color: "text-green-600",
bgColor: "bg-green-50",
},
{
title: "Pending Approvals",
value: stats.pending,
icon: Clock,
color: "text-orange-600",
bgColor: "bg-orange-50",
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{cards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.title} className="p-6 hover:shadow-sm transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">{card.title}</p>
<p className="text-3xl font-bold mt-2">{card.value}</p>
</div>
<div className={`p-3 rounded-lg ${card.bgColor}`}>
<Icon className={`h-6 w-6 ${card.color}`} />
</div>
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { Drawing } from "@/types/drawing";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileText, Download, Eye, GitCompare } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
export function DrawingCard({ drawing }: { drawing: Drawing }) {
return (
<Card className="p-6 hover:shadow-md transition-shadow">
<div className="flex gap-4">
{/* Thumbnail Placeholder */}
<div className="w-24 h-24 bg-muted rounded flex items-center justify-center shrink-0">
<FileText className="h-10 w-10 text-muted-foreground" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-lg font-semibold truncate" title={drawing.drawing_number}>
{drawing.drawing_number}
</h3>
<p className="text-sm text-muted-foreground truncate" title={drawing.title}>
{drawing.title}
</p>
</div>
<Badge variant="outline">{drawing.discipline?.discipline_code}</Badge>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
<div>
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheet_number}
</div>
<div>
<span className="font-medium text-foreground">Rev:</span> {drawing.current_revision}
</div>
<div>
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
</div>
<div>
<span className="font-medium text-foreground">Date:</span>{" "}
{format(new Date(drawing.issue_date), "dd/MM/yyyy")}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Link href={`/drawings/${drawing.drawing_id}`}>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
View
</Button>
</Link>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Download
</Button>
{drawing.revision_count > 1 && (
<Button variant="outline" size="sm">
<GitCompare className="mr-2 h-4 w-4" />
Compare
</Button>
)}
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Drawing } from "@/types/drawing";
import { DrawingCard } from "@/components/drawings/card";
import { drawingApi } from "@/lib/api/drawings";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
interface DrawingListProps {
type: "CONTRACT" | "SHOP";
}
export function DrawingList({ type }: DrawingListProps) {
const [drawings, setDrawings] = useState<Drawing[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDrawings = async () => {
setLoading(true);
try {
const data = await drawingApi.getAll({ type });
setDrawings(data);
} catch (error) {
console.error("Failed to fetch drawings", error);
} finally {
setLoading(false);
}
};
fetchDrawings();
}, [type]);
if (loading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (drawings.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
No drawings found.
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{drawings.map((drawing) => (
<DrawingCard key={drawing.drawing_id} drawing={drawing} />
))}
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { DrawingRevision } from "@/types/drawing";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Download, FileText } from "lucide-react";
import { format } from "date-fns";
export function RevisionHistory({ revisions }: { revisions: DrawingRevision[] }) {
return (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Revision History</h3>
<div className="space-y-3">
{revisions.map((rev) => (
<div
key={rev.revision_id}
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<Badge variant={rev.is_current ? "default" : "outline"}>
Rev. {rev.revision_number}
</Badge>
{rev.is_current && (
<span className="text-xs text-green-600 font-medium flex items-center gap-1">
<div className="h-1.5 w-1.5 rounded-full bg-green-600" />
CURRENT
</span>
)}
</div>
<p className="text-sm text-foreground font-medium">
{rev.revision_description || "No description"}
</p>
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(rev.revision_date), "dd MMM yyyy")} by{" "}
{rev.revised_by_name}
</p>
</div>
<Button variant="ghost" size="sm" title="Download">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card } from "@/components/ui/card";
import { useRouter } from "next/navigation";
import { drawingApi } from "@/lib/api/drawings";
import { useState } from "react";
import { Loader2 } from "lucide-react";
const drawingSchema = z.object({
drawing_type: z.enum(["CONTRACT", "SHOP"], { required_error: "Type is required" }),
drawing_number: z.string().min(1, "Drawing Number is required"),
title: z.string().min(5, "Title must be at least 5 characters"),
discipline_id: z.number({ required_error: "Discipline is required" }),
sheet_number: z.string().min(1, "Sheet Number is required"),
scale: z.string().optional(),
file: z.instanceof(File, { message: "File is required" }),
});
type DrawingFormData = z.infer<typeof drawingSchema>;
export function DrawingUploadForm() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<DrawingFormData>({
resolver: zodResolver(drawingSchema),
});
const onSubmit = async (data: DrawingFormData) => {
setIsSubmitting(true);
try {
await drawingApi.create(data as any);
router.push("/drawings");
router.refresh();
} catch (error) {
console.error(error);
alert("Failed to upload drawing");
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
<div className="space-y-4">
<div>
<Label>Drawing Type *</Label>
<Select onValueChange={(v) => setValue("drawing_type", v as any)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CONTRACT">Contract Drawing</SelectItem>
<SelectItem value="SHOP">Shop Drawing</SelectItem>
</SelectContent>
</Select>
{errors.drawing_type && (
<p className="text-sm text-destructive mt-1">{errors.drawing_type.message}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="drawing_number">Drawing Number *</Label>
<Input id="drawing_number" {...register("drawing_number")} placeholder="e.g. A-101" />
{errors.drawing_number && (
<p className="text-sm text-destructive mt-1">{errors.drawing_number.message}</p>
)}
</div>
<div>
<Label htmlFor="sheet_number">Sheet Number *</Label>
<Input id="sheet_number" {...register("sheet_number")} placeholder="e.g. 01" />
{errors.sheet_number && (
<p className="text-sm text-destructive mt-1">{errors.sheet_number.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="title">Title *</Label>
<Input id="title" {...register("title")} placeholder="Drawing Title" />
{errors.title && (
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Discipline *</Label>
<Select
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select Discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">STR - Structure</SelectItem>
<SelectItem value="2">ARC - Architecture</SelectItem>
<SelectItem value="3">ELE - Electrical</SelectItem>
<SelectItem value="4">MEC - Mechanical</SelectItem>
</SelectContent>
</Select>
{errors.discipline_id && (
<p className="text-sm text-destructive mt-1">{errors.discipline_id.message}</p>
)}
</div>
<div>
<Label htmlFor="scale">Scale</Label>
<Input id="scale" {...register("scale")} placeholder="e.g. 1:100" />
</div>
</div>
<div>
<Label htmlFor="file">Drawing File *</Label>
<Input
id="file"
type="file"
accept=".pdf,.dwg"
className="cursor-pointer"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setValue("file", file);
}}
/>
<p className="text-xs text-muted-foreground mt-1">
Accepted: PDF, DWG (Max 50MB)
</p>
{errors.file && (
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
)}
</div>
</div>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Upload Drawing
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Search, FileText, Clipboard, Image } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { searchApi } from "@/lib/api/search";
import { SearchResult } from "@/types/search";
import { useDebounce } from "@/hooks/use-debounce"; // We need to create this hook or implement debounce inline
// Simple debounce hook implementation inline for now if not exists
function useDebounceValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export function GlobalSearch() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const debouncedQuery = useDebounceValue(query, 300);
useEffect(() => {
if (debouncedQuery.length > 2) {
searchApi.suggest(debouncedQuery).then(setSuggestions);
setOpen(true);
} else {
setSuggestions([]);
if (debouncedQuery.length === 0) setOpen(false);
}
}, [debouncedQuery]);
const handleSearch = () => {
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query)}`);
setOpen(false);
}
};
const getIcon = (type: string) => {
switch (type) {
case "correspondence": return <FileText className="mr-2 h-4 w-4" />;
case "rfa": return <Clipboard className="mr-2 h-4 w-4" />;
case "drawing": return <Image className="mr-2 h-4 w-4" />;
default: return <Search className="mr-2 h-4 w-4" />;
}
};
return (
<div className="relative w-full max-w-sm">
<Popover open={open && suggestions.length > 0} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search documents..."
className="pl-8 w-full bg-background"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
onFocus={() => {
if (suggestions.length > 0) setOpen(true);
}}
/>
</div>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]" align="start">
<Command>
<CommandList>
<CommandGroup heading="Suggestions">
{suggestions.map((item) => (
<CommandItem
key={`${item.type}-${item.id}`}
onSelect={() => {
setQuery(item.title);
router.push(`/${item.type}s/${item.id}`);
setOpen(false);
}}
>
{getIcon(item.type)}
<span>{item.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { UserMenu } from "./user-menu";
import { Button } from "@/components/ui/button";
import { GlobalSearch } from "./global-search";
import { NotificationsDropdown } from "./notifications-dropdown";
export function Header() {
return (
<header className="h-16 border-b bg-white flex items-center justify-between px-6 sticky top-0 z-10">
<div className="flex items-center gap-4 flex-1">
<h2 className="text-lg font-semibold text-gray-800">LCBP3-DMS</h2>
<div className="ml-4 w-full max-w-md">
<GlobalSearch />
</div>
</div>
<div className="flex items-center gap-4">
<NotificationsDropdown />
<UserMenu />
</div>
</header>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import { Bell, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { notificationApi } from "@/lib/api/notifications";
import { Notification } from "@/types/notification";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { useRouter } from "next/navigation";
export function NotificationsDropdown() {
const router = useRouter();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Fetch notifications
const fetchNotifications = async () => {
try {
const data = await notificationApi.getUnread();
setNotifications(data.items);
setUnreadCount(data.unreadCount);
} catch (error) {
console.error("Failed to fetch notifications", error);
}
};
fetchNotifications();
}, []);
const handleMarkAsRead = async (id: number, e: React.MouseEvent) => {
e.stopPropagation();
await notificationApi.markAsRead(id);
setNotifications((prev) =>
prev.map((n) => (n.notification_id === id ? { ...n, is_read: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
};
const handleNotificationClick = async (notification: Notification) => {
if (!notification.is_read) {
await notificationApi.markAsRead(notification.notification_id);
setUnreadCount((prev) => Math.max(0, prev - 1));
}
setIsOpen(false);
if (notification.link) {
router.push(notification.link);
}
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5 text-gray-600" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-[10px] rounded-full"
>
{unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="flex justify-between items-center">
<span>Notifications</span>
{unreadCount > 0 && (
<span className="text-xs font-normal text-muted-foreground">
{unreadCount} unread
</span>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No notifications
</div>
) : (
<div className="max-h-[400px] overflow-y-auto">
{notifications.map((notification) => (
<DropdownMenuItem
key={notification.notification_id}
className={`flex flex-col items-start p-3 cursor-pointer ${
!notification.is_read ? "bg-muted/30" : ""
}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex w-full justify-between items-start gap-2">
<div className="font-medium text-sm line-clamp-1">
{notification.title}
</div>
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-primary"
onClick={(e) => handleMarkAsRead(notification.notification_id, e)}
title="Mark as read"
>
<Check className="h-3 w-3" />
</Button>
)}
</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{notification.message}
</div>
<div className="text-[10px] text-muted-foreground mt-2 w-full text-right">
{formatDistanceToNow(new Date(notification.created_at), {
addSuffix: true,
})}
</div>
</DropdownMenuItem>
))}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-center justify-center text-xs text-muted-foreground cursor-pointer">
View All Notifications
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,126 +1,140 @@
// File: components/layout/sidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/lib/stores/ui-store";
import { sidebarMenuItems, adminMenuItems } from "@/config/menu";
import {
LayoutDashboard,
FileText,
FileCheck,
PenTool,
Search,
Settings,
Shield,
Menu,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ChevronLeft, Menu, X } from "lucide-react";
import { useEffect } from "react"; // ✅ Import useEffect
import { useState } from "react";
import { Can } from "@/components/common/can";
export function Sidebar() {
interface SidebarProps {
className?: string;
}
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname();
const { isSidebarOpen, toggleSidebar, closeSidebar } = useUIStore();
const [collapsed, setCollapsed] = useState(false);
// ✅ เพิ่ม Logic นี้: ปิด Sidebar อัตโนมัติเมื่อหน้าจอเล็กกว่า 768px (Mobile)
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768 && isSidebarOpen) {
closeSidebar();
}
};
// ติดตั้ง Listener
window.addEventListener("resize", handleResize);
// ล้าง Listener เมื่อ Component ถูกทำลาย
return () => window.removeEventListener("resize", handleResize);
}, [isSidebarOpen, closeSidebar]);
const navItems = [
{
title: "Dashboard",
href: "/dashboard",
icon: LayoutDashboard,
permission: null, // Everyone can see
},
{
title: "Correspondences",
href: "/correspondences",
icon: FileText,
permission: null,
},
{
title: "RFAs",
href: "/rfas",
icon: FileCheck,
permission: null,
},
{
title: "Drawings",
href: "/drawings",
icon: PenTool,
permission: null,
},
{
title: "Search",
href: "/search",
icon: Search,
permission: null,
},
{
title: "Admin Panel",
href: "/admin",
icon: Shield,
permission: "admin", // Only admins
},
];
return (
<>
{/* Mobile Overlay */}
<div
className={cn(
"fixed inset-0 z-40 bg-background/80 backdrop-blur-sm transition-all duration-100 md:hidden",
isSidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"
<div
className={cn(
"flex flex-col h-screen border-r bg-card transition-all duration-300",
collapsed ? "w-16" : "w-64",
className
)}
>
<div className="h-16 flex items-center justify-between px-4 border-b">
{!collapsed && (
<span className="text-lg font-bold text-primary truncate">
LCBP3 DMS
</span>
)}
onClick={closeSidebar}
/>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(!collapsed)}
className={cn("ml-auto", collapsed && "mx-auto")}
>
<Menu className="h-5 w-5" />
</Button>
</div>
{/* Sidebar Container */}
<aside
className={cn(
"fixed top-0 left-0 z-50 h-screen border-r bg-card transition-all duration-300 ease-in-out flex flex-col",
// Mobile Width
"w-[240px]",
isSidebarOpen ? "translate-x-0" : "-translate-x-full",
<div className="flex-1 overflow-y-auto py-4">
<nav className="grid gap-1 px-2">
{navItems.map((item, index) => {
const isActive = pathname.startsWith(item.href);
// Desktop Styles
"md:translate-x-0",
isSidebarOpen ? "md:w-[240px]" : "md:w-[70px]"
)}
>
<div className={cn(
"flex h-14 items-center border-b px-3 lg:h-[60px]",
"justify-between md:justify-center",
isSidebarOpen && "md:justify-between"
)}>
<div className={cn(
"flex items-center gap-2 font-bold text-primary truncate transition-all duration-300",
!isSidebarOpen && "md:w-0 md:opacity-0 md:hidden"
)}>
<Link href="/dashboard">LCBP3 DMS</Link>
</div>
{/* Desktop Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="hidden md:flex h-8 w-8"
>
{isSidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</Button>
{/* Mobile Close Button */}
<Button
variant="ghost"
size="icon"
onClick={closeSidebar} // ปุ่มนี้จะทำงานได้ถูกต้อง
className="md:hidden h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-4">
<nav className="grid gap-1 px-2">
{sidebarMenuItems.map((item, index) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
const LinkComponent = (
<Link
key={index}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground",
collapsed && "justify-center px-2"
)}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5" />
{!collapsed && <span>{item.title}</span>}
</Link>
);
if (item.permission) {
return (
<Link
key={index}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground",
!isSidebarOpen && "md:justify-center md:px-2"
)}
title={!isSidebarOpen ? item.title : undefined}
onClick={() => {
if (window.innerWidth < 768) closeSidebar();
}}
>
<Icon className="h-4 w-4 shrink-0" />
<span className={cn(
"truncate transition-all duration-300",
!isSidebarOpen && "md:w-0 md:opacity-0 md:hidden"
)}>
{item.title}
</span>
</Link>
<Can key={index} permission={item.permission}>
{LinkComponent}
</Can>
);
})}
</nav>
</div>
</aside>
</>
}
return LinkComponent;
})}
</nav>
</div>
<div className="p-4 border-t">
<Link
href="/settings"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
collapsed && "justify-center px-2"
)}
title="Settings"
>
<Settings className="h-5 w-5" />
{!collapsed && <span>Settings</span>}
</Link>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,81 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LogOut, Settings, User } from "lucide-react";
export function UserMenu() {
const router = useRouter();
const { data: session } = useSession();
const user = session?.user;
if (!user) return null;
// Generate initials from name or username
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const initials = user.name ? getInitials(user.name) : "U";
const handleLogout = async () => {
await signOut({ redirect: false });
router.push("/login");
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-primary/10 text-primary">
{initials}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
<p className="text-xs leading-none text-muted-foreground mt-1">
Role: {user.role}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,92 @@
"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";
export function SequenceViewer({ templateId }: { templateId: number }) {
const [sequences, setSequences] = useState<NumberingSequence[]>([]);
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);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (templateId) {
fetchSequences();
}
}, [templateId]);
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" />}
Refresh
</Button>
</div>
<div className="mb-4">
<Input
placeholder="Search by year, organization..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</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>
))
)}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,203 @@
"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";
import { CreateTemplateDto } from "@/types/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" },
];
interface TemplateEditorProps {
initialData?: Partial<CreateTemplateDto>;
onSave: (data: CreateTemplateDto) => 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("");
useEffect(() => {
// Generate preview
let previewText = formData.template_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);
});
setPreview(previewText);
}, [formData.template_format]);
const insertVariable = (variable: string) => {
setFormData((prev) => ({
...prev,
template_format: prev.template_format + 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
value={formData.document_type_id}
onValueChange={(value) => setFormData({ ...formData, document_type_id: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="drawing">Drawing</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select
value={formData.discipline_code}
onValueChange={(value) => setFormData({ ...formData, discipline_code: value })}
>
<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={formData.template_format}
onChange={(e) => setFormData({ ...formData, template_format: 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-muted-foreground 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"
value={formData.padding_length}
onChange={(e) => setFormData({ ...formData, padding_length: parseInt(e.target.value) })}
min={1}
max={10}
/>
<p className="text-xs text-muted-foreground mt-1">
Number of digits (e.g., 4 = 0001, 0002)
</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>
</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-muted/50 rounded"
>
<div>
<Badge variant="outline" className="font-mono">
{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>
</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>
</div>
</Card>
);
}

View File

@@ -0,0 +1,110 @@
"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";
import { NumberingTemplate } from "@/types/numbering";
import { numberingApi } from "@/lib/api/numbering";
import { Loader2 } from "lucide-react";
interface TemplateTesterProps {
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",
year: new Date().getFullYear(),
});
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");
} finally {
setLoading(false);
}
};
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}
onValueChange={(value) => setTestData({ ...testData, organization_id: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">.</SelectItem>
<SelectItem value="2">©.</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select
value={testData.discipline_id}
onValueChange={(value) => setTestData({ ...testData, discipline_id: value })}
>
<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" disabled={loading || !template}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Test Number
</Button>
{generatedNumber && (
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-muted-foreground mb-1">Generated Number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{generatedNumber}
</p>
</Card>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import { RFA } from "@/types/rfa";
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, 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";
import { useRouter } from "next/navigation";
interface RFADetailProps {
data: RFA;
}
export function RFADetail({ data }: RFADetailProps) {
const router = useRouter();
const [approvalDialog, setApprovalDialog] = useState<"approve" | "reject" | null>(null);
const [comments, setComments] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const handleApproval = async (action: "approve" | "reject") => {
setIsProcessing(true);
try {
const newStatus = action === "approve" ? "APPROVED" : "REJECTED";
await rfaApi.updateStatus(data.rfa_id, newStatus, comments);
setApprovalDialog(null);
router.refresh();
} catch (error) {
console.error(error);
alert("Failed to update status");
} finally {
setIsProcessing(false);
}
};
return (
<div className="space-y-6">
{/* Header / Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/rfas">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{data.rfa_number}</h1>
<p className="text-muted-foreground">
Created on {format(new Date(data.created_at), "dd MMM yyyy HH:mm")}
</p>
</div>
</div>
{data.status === "PENDING" && (
<div className="flex gap-2">
<Button
variant="outline"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setApprovalDialog("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")}
>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl">{data.subject}</CardTitle>
<StatusBadge status={data.status} />
</div>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-gray-700 whitespace-pre-wrap">
{data.description || "No description provided."}
</p>
</div>
<Separator />
<div>
<h3 className="font-semibold mb-3">RFA Items</h3>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">Item No.</th>
<th className="px-4 py-3 text-left font-medium">Description</th>
<th className="px-4 py-3 text-right font-medium">Qty</th>
<th className="px-4 py-3 text-left font-medium">Unit</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{data.items.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3 font-medium">{item.item_no}</td>
<td className="px-4 py-3">{item.description}</td>
<td className="px-4 py-3 text-right">{item.quantity}</td>
<td className="px-4 py-3 text-muted-foreground">{item.unit}</td>
<td className="px-4 py-3">
<StatusBadge status={item.status || "PENDING"} className="text-[10px] px-2 py-0.5 h-5" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar Info */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Contract</p>
<p className="font-medium mt-1">{data.contract_name}</p>
</div>
<Separator />
<div>
<p className="text-sm font-medium text-muted-foreground">Discipline</p>
<p className="font-medium mt-1">{data.discipline_name}</p>
</div>
</CardContent>
</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={isProcessing}>
Cancel
</Button>
<Button
variant={approvalDialog === "approve" ? "default" : "destructive"}
onClick={() => handleApproval(approvalDialog!)}
disabled={isProcessing}
className={approvalDialog === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
>
{isProcessing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{approvalDialog === "approve" ? "Confirm Approval" : "Confirm Rejection"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,237 @@
"use client";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { Plus, Trash2, Loader2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useRouter } from "next/navigation";
import { rfaApi } from "@/lib/api/rfas";
import { useState } from "react";
const rfaItemSchema = z.object({
item_no: z.string().min(1, "Item No is required"),
description: z.string().min(3, "Description is required"),
quantity: z.number({ invalid_type_error: "Quantity must be a number" }).min(0),
unit: z.string().min(1, "Unit is required"),
});
const rfaSchema = z.object({
subject: z.string().min(5, "Subject must be at least 5 characters"),
description: z.string().optional(),
contract_id: z.number({ required_error: "Contract is required" }),
discipline_id: z.number({ required_error: "Discipline is required" }),
items: z.array(rfaItemSchema).min(1, "At least one item is required"),
});
type RFAFormData = z.infer<typeof rfaSchema>;
export function RFAForm() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<RFAFormData>({
resolver: zodResolver(rfaSchema),
defaultValues: {
items: [{ item_no: "1", description: "", quantity: 0, unit: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
const onSubmit = async (data: RFAFormData) => {
setIsSubmitting(true);
try {
await rfaApi.create(data as any);
router.push("/rfas");
router.refresh();
} catch (error) {
console.error(error);
alert("Failed to create RFA");
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl space-y-6">
{/* Basic Info */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">RFA Information</h3>
<div className="space-y-4">
<div>
<Label htmlFor="subject">Subject *</Label>
<Input id="subject" {...register("subject")} placeholder="Enter subject" />
{errors.subject && (
<p className="text-sm text-destructive mt-1">
{errors.subject.message}
</p>
)}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input id="description" {...register("description")} placeholder="Enter description" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Contract *</Label>
<Select
onValueChange={(v) => setValue("contract_id", parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select Contract" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Main Construction Contract</SelectItem>
<SelectItem value="2">Subcontract A</SelectItem>
</SelectContent>
</Select>
{errors.contract_id && (
<p className="text-sm text-destructive mt-1">{errors.contract_id.message}</p>
)}
</div>
<div>
<Label>Discipline *</Label>
<Select
onValueChange={(v) => setValue("discipline_id", parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select Discipline" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Civil</SelectItem>
<SelectItem value="2">Structural</SelectItem>
<SelectItem value="3">Electrical</SelectItem>
<SelectItem value="4">Mechanical</SelectItem>
</SelectContent>
</Select>
{errors.discipline_id && (
<p className="text-sm text-destructive mt-1">{errors.discipline_id.message}</p>
)}
</div>
</div>
</div>
</Card>
{/* RFA Items */}
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">RFA Items</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
item_no: (fields.length + 1).toString(),
description: "",
quantity: 0,
unit: "",
})
}
>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
<div className="space-y-4">
{fields.map((field, index) => (
<Card key={field.id} className="p-4 bg-muted/20">
<div className="flex justify-between items-start mb-3">
<h4 className="font-medium text-sm">Item #{index + 1}</h4>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
<div className="md:col-span-2">
<Label className="text-xs">Item No.</Label>
<Input {...register(`items.${index}.item_no`)} placeholder="1.1" />
{errors.items?.[index]?.item_no && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.item_no?.message}</p>
)}
</div>
<div className="md:col-span-6">
<Label className="text-xs">Description *</Label>
<Input {...register(`items.${index}.description`)} placeholder="Item description" />
{errors.items?.[index]?.description && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.description?.message}</p>
)}
</div>
<div className="md:col-span-2">
<Label className="text-xs">Quantity</Label>
<Input
type="number"
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
})}
/>
{errors.items?.[index]?.quantity && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.quantity?.message}</p>
)}
</div>
<div className="md:col-span-2">
<Label className="text-xs">Unit</Label>
<Input {...register(`items.${index}.unit`)} placeholder="pcs, m3" />
{errors.items?.[index]?.unit && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.unit?.message}</p>
)}
</div>
</div>
</Card>
))}
</div>
{errors.items?.root && (
<p className="text-sm text-destructive mt-2">
{errors.items.root.message}
</p>
)}
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create RFA
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { RFA } from "@/types/rfa";
import { DataTable } from "@/components/common/data-table";
import { ColumnDef } from "@tanstack/react-table";
import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button";
import { Eye } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
interface RFAListProps {
data: {
items: RFA[];
total: number;
page: number;
totalPages: number;
};
}
export function RFAList({ data }: RFAListProps) {
const columns: ColumnDef<RFA>[] = [
{
accessorKey: "rfa_number",
header: "RFA No.",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("rfa_number")}</span>
),
},
{
accessorKey: "subject",
header: "Subject",
cell: ({ row }) => (
<div className="max-w-[300px] truncate" title={row.getValue("subject")}>
{row.getValue("subject")}
</div>
),
},
{
accessorKey: "contract_name",
header: "Contract",
},
{
accessorKey: "discipline_name",
header: "Discipline",
},
{
accessorKey: "created_at",
header: "Date",
cell: ({ row }) => format(new Date(row.getValue("created_at")), "dd MMM yyyy"),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
},
{
id: "actions",
cell: ({ row }) => {
const item = row.original;
return (
<div className="flex gap-2">
<Link href={`/rfas/${item.rfa_id}`}>
<Button variant="ghost" size="icon" title="View">
<Eye className="h-4 w-4" />
</Button>
</Link>
</div>
);
},
},
];
return (
<div>
<DataTable columns={columns} data={data.items} />
{/* Pagination component would go here */}
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { SearchFilters as FilterType } from "@/types/search";
import { useState } from "react";
interface SearchFiltersProps {
onFilterChange: (filters: FilterType) => void;
}
export function SearchFilters({ onFilterChange }: SearchFiltersProps) {
const [filters, setFilters] = useState<FilterType>({
types: [],
statuses: [],
});
const handleTypeChange = (type: string, checked: boolean) => {
const currentTypes = filters.types || [];
const newTypes = checked
? [...currentTypes, type]
: currentTypes.filter((t) => t !== type);
const newFilters = { ...filters, types: newTypes };
setFilters(newFilters);
onFilterChange(newFilters);
};
const handleStatusChange = (status: string, checked: boolean) => {
const currentStatuses = filters.statuses || [];
const newStatuses = checked
? [...currentStatuses, status]
: currentStatuses.filter((s) => s !== status);
const newFilters = { ...filters, statuses: newStatuses };
setFilters(newFilters);
onFilterChange(newFilters);
};
const clearFilters = () => {
const newFilters = { types: [], statuses: [] };
setFilters(newFilters);
onFilterChange(newFilters);
};
return (
<Card className="p-4 space-y-6">
<div>
<h3 className="font-semibold mb-3">Document Type</h3>
<div className="space-y-2">
{["correspondence", "rfa", "drawing"].map((type) => (
<div key={type} className="flex items-center space-x-2">
<Checkbox
id={`type-${type}`}
checked={filters.types?.includes(type)}
onCheckedChange={(checked) => handleTypeChange(type, checked as boolean)}
/>
<Label htmlFor={`type-${type}`} className="text-sm capitalize">
{type}
</Label>
</div>
))}
</div>
</div>
<div>
<h3 className="font-semibold mb-3">Status</h3>
<div className="space-y-2">
{["DRAFT", "PENDING", "APPROVED", "REJECTED", "IN_REVIEW"].map((status) => (
<div key={status} className="flex items-center space-x-2">
<Checkbox
id={`status-${status}`}
checked={filters.statuses?.includes(status)}
onCheckedChange={(checked) => handleStatusChange(status, checked as boolean)}
/>
<Label htmlFor={`status-${status}`} className="text-sm capitalize">
{status.replace("_", " ").toLowerCase()}
</Label>
</div>
))}
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={clearFilters}
>
Clear Filters
</Button>
</Card>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { FileText, Clipboard, Image, Loader2 } from "lucide-react";
import { SearchResult } from "@/types/search";
import { format } from "date-fns";
interface SearchResultsProps {
results: SearchResult[];
query: string;
loading: boolean;
}
export function SearchResults({ results, query, loading }: SearchResultsProps) {
if (loading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (results.length === 0) {
return (
<Card className="p-12 text-center text-muted-foreground">
{query ? `No results found for "${query}"` : "Enter a search term to start"}
</Card>
);
}
const getIcon = (type: string) => {
switch (type) {
case "correspondence":
return FileText;
case "rfa":
return Clipboard;
case "drawing":
return Image;
default:
return FileText;
}
};
const getLink = (result: SearchResult) => {
return `/${result.type}s/${result.id}`; // Assuming routes are plural (correspondences, rfas, drawings)
};
return (
<div className="space-y-4">
{results.map((result, index) => {
const Icon = getIcon(result.type);
return (
<Card
key={`${result.type}-${result.id}-${index}`}
className="p-6 hover:shadow-md transition-shadow group"
>
<Link href={getLink(result)}>
<div className="flex gap-4">
<div className="flex-shrink-0 mt-1">
<Icon className="h-6 w-6 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3
className="text-lg font-semibold group-hover:text-primary transition-colors"
dangerouslySetInnerHTML={{
__html: result.highlight || result.title
}}
/>
<Badge variant="secondary" className="capitalize">{result.type}</Badge>
<Badge variant="outline">{result.status}</Badge>
</div>
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">
{result.description}
</p>
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="font-medium">{result.documentNumber}</span>
<span></span>
<span>
{format(new Date(result.createdAt), "dd MMM yyyy")}
</span>
</div>
</div>
</div>
</Link>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,115 @@
"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";
interface DSLEditorProps {
initialValue?: string;
onChange?: (value: string) => void;
}
export function DSLEditor({ initialValue = "", onChange }: DSLEditorProps) {
const [dsl, setDsl] = useState(initialValue);
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
const handleEditorChange = (value: string | undefined) => {
const newValue = value || "";
setDsl(newValue);
onChange?.(newValue);
setValidationResult(null); // Clear validation on change
};
const validateDSL = async () => {
setIsValidating(true);
try {
const result = await workflowApi.validateDSL(dsl);
setValidationResult(result);
} catch (error) {
console.error(error);
setValidationResult({ valid: false, errors: ["Validation failed due to an error"] });
} finally {
setIsValidating(false);
}
};
const testWorkflow = async () => {
alert("Test workflow functionality to be implemented");
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Workflow DSL</h3>
<div className="flex gap-2">
<Button
variant="outline"
onClick={validateDSL}
disabled={isValidating}
>
{isValidating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle className="mr-2 h-4 w-4" />
)}
Validate
</Button>
<Button variant="outline" onClick={testWorkflow}>
<Play className="mr-2 h-4 w-4" />
Test
</Button>
</div>
</div>
<Card className="overflow-hidden border rounded-md">
<Editor
height="500px"
defaultLanguage="yaml"
value={dsl}
onChange={handleEditorChange}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
rulers: [80],
wordWrap: "on",
scrollBeyondLastLine: false,
}}
/>
</Card>
{validationResult && (
<Alert variant={validationResult.valid ? "default" : "destructive"} className={validationResult.valid ? "border-green-500 text-green-700 bg-green-50" : ""}>
{validationResult.valid ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>
{validationResult.valid ? (
"DSL is valid ✓"
) : (
<div>
<p className="font-medium mb-2">Validation Errors:</p>
<ul className="list-disc list-inside space-y-1">
{validationResult.errors?.map((error: string, i: number) => (
<li key={i} className="text-sm">
{error}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useCallback } from "react";
import ReactFlow, {
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Connection,
} from "reactflow";
import "reactflow/dist/style.css";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
const nodeTypes = {
// We can define custom node types here if needed
};
// Color mapping for node types
const nodeColors: Record<string, string> = {
start: "#10b981", // green
step: "#3b82f6", // blue
condition: "#f59e0b", // amber
end: "#ef4444", // red
};
export function VisualWorkflowBuilder() {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
const addNode = (type: string) => {
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,
},
};
setNodes((nds) => [...nds, newNode]);
};
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));
};
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">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
>
<Controls />
<Background color="#aaa" gap={16} />
</ReactFlow>
</Card>
</div>
);
}

103
frontend/lib/api/admin.ts Normal file
View File

@@ -0,0 +1,103 @@
import { User, CreateUserDto, Organization, AuditLog } from "@/types/admin";
// Mock Data
const mockUsers: User[] = [
{
user_id: 1,
username: "admin",
email: "admin@example.com",
first_name: "System",
last_name: "Admin",
is_active: true,
roles: [{ role_id: 1, role_name: "ADMIN", description: "Administrator" }],
},
{
user_id: 2,
username: "jdoe",
email: "john.doe@example.com",
first_name: "John",
last_name: "Doe",
is_active: true,
roles: [{ role_id: 2, role_name: "USER", description: "Regular User" }],
},
];
const mockOrgs: Organization[] = [
{
org_id: 1,
org_code: "PAT",
org_name: "Port Authority of Thailand",
org_name_th: "การท่าเรือแห่งประเทศไทย",
description: "Owner",
},
{
org_id: 2,
org_code: "CNPC",
org_name: "CNPC Consortium",
description: "Main Contractor",
},
];
const mockLogs: AuditLog[] = [
{
audit_log_id: 1,
user_name: "admin",
action: "CREATE",
entity_type: "user",
description: "Created user 'jdoe'",
ip_address: "192.168.1.1",
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
},
{
audit_log_id: 2,
user_name: "jdoe",
action: "UPDATE",
entity_type: "rfa",
description: "Updated status of RFA-001 to APPROVED",
ip_address: "192.168.1.5",
created_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
},
];
export const adminApi = {
getUsers: async (): Promise<User[]> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return [...mockUsers];
},
createUser: async (data: CreateUserDto): Promise<User> => {
await new Promise((resolve) => setTimeout(resolve, 800));
const newUser: User = {
user_id: Math.max(...mockUsers.map((u) => u.user_id)) + 1,
username: data.username,
email: data.email,
first_name: data.first_name,
last_name: data.last_name,
is_active: data.is_active,
roles: data.roles.map((id) => ({
role_id: id,
role_name: id === 1 ? "ADMIN" : "USER",
description: "",
})),
};
mockUsers.push(newUser);
return newUser;
},
getOrganizations: async (): Promise<Organization[]> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return [...mockOrgs];
},
createOrganization: async (data: Omit<Organization, "org_id">): Promise<Organization> => {
await new Promise((resolve) => setTimeout(resolve, 600));
const newOrg = { ...data, org_id: Math.max(...mockOrgs.map((o) => o.org_id)) + 1 };
mockOrgs.push(newOrg);
return newOrg;
},
getAuditLogs: async (): Promise<AuditLog[]> => {
await new Promise((resolve) => setTimeout(resolve, 400));
return [...mockLogs];
},
};

View File

@@ -0,0 +1,85 @@
import { Correspondence, CreateCorrespondenceDto } from "@/types/correspondence";
// Mock Data
const mockCorrespondences: Correspondence[] = [
{
correspondence_id: 1,
document_number: "LCBP3-COR-001",
subject: "Submission of Monthly Report - Jan 2025",
description: "Please find attached the monthly progress report.",
status: "PENDING",
importance: "NORMAL",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
from_organization_id: 1,
to_organization_id: 2,
document_type_id: 1,
from_organization: { id: 1, org_name: "Contractor A", org_code: "CON-A" },
to_organization: { id: 2, org_name: "Owner", org_code: "OWN" },
},
{
correspondence_id: 2,
document_number: "LCBP3-COR-002",
subject: "Request for Information regarding Foundation",
description: "Clarification needed on drawing A-101.",
status: "IN_REVIEW",
importance: "HIGH",
created_at: new Date(Date.now() - 86400000).toISOString(),
updated_at: new Date(Date.now() - 86400000).toISOString(),
from_organization_id: 2,
to_organization_id: 1,
document_type_id: 1,
from_organization: { id: 2, org_name: "Owner", org_code: "OWN" },
to_organization: { id: 1, org_name: "Contractor A", org_code: "CON-A" },
},
];
export const correspondenceApi = {
getAll: async (params?: { page?: number; status?: string; search?: string }) => {
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 500));
let filtered = [...mockCorrespondences];
if (params?.status) {
filtered = filtered.filter((c) => c.status === params.status);
}
if (params?.search) {
const lowerSearch = params.search.toLowerCase();
filtered = filtered.filter((c) =>
c.subject.toLowerCase().includes(lowerSearch) ||
c.document_number.toLowerCase().includes(lowerSearch)
);
}
return {
items: filtered,
total: filtered.length,
page: params?.page || 1,
totalPages: 1,
};
},
getById: async (id: number) => {
await new Promise((resolve) => setTimeout(resolve, 500));
return mockCorrespondences.find((c) => c.correspondence_id === id);
},
create: async (data: CreateCorrespondenceDto) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const newId = Math.max(...mockCorrespondences.map((c) => c.correspondence_id)) + 1;
const newCorrespondence: Correspondence = {
correspondence_id: newId,
document_number: `LCBP3-COR-00${newId}`,
...data,
status: "DRAFT",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
// Mock organizations for display
from_organization: { id: data.from_organization_id, org_name: "Mock Org From", org_code: "MOCK" },
to_organization: { id: data.to_organization_id, org_name: "Mock Org To", org_code: "MOCK" },
} as Correspondence; // Casting for simplicity in mock
mockCorrespondences.unshift(newCorrespondence);
return newCorrespondence;
},
};

View File

@@ -0,0 +1,65 @@
import { DashboardStats, ActivityLog, PendingTask } from "@/types/dashboard";
export const dashboardApi = {
getStats: async (): Promise<DashboardStats> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return {
correspondences: 124,
rfas: 45,
approved: 89,
pending: 12,
};
},
getRecentActivity: async (): Promise<ActivityLog[]> => {
await new Promise((resolve) => setTimeout(resolve, 600));
return [
{
id: 1,
user: { name: "John Doe", initials: "JD" },
action: "Created RFA",
description: "RFA-001: Concrete Pouring Request",
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
targetUrl: "/rfas/1",
},
{
id: 2,
user: { name: "Jane Smith", initials: "JS" },
action: "Approved Correspondence",
description: "COR-005: Site Safety Report",
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2 hours ago
targetUrl: "/correspondences/5",
},
{
id: 3,
user: { name: "Mike Johnson", initials: "MJ" },
action: "Uploaded Drawing",
description: "A-101: Ground Floor Plan Rev B",
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
targetUrl: "/drawings/1",
},
];
},
getPendingTasks: async (): Promise<PendingTask[]> => {
await new Promise((resolve) => setTimeout(resolve, 400));
return [
{
id: 1,
title: "Review RFA-002",
description: "Approval required for steel reinforcement",
daysOverdue: 2,
url: "/rfas/2",
priority: "HIGH",
},
{
id: 2,
title: "Approve Monthly Report",
description: "January 2025 Progress Report",
daysOverdue: 0,
url: "/correspondences/10",
priority: "MEDIUM",
},
];
},
};

View File

@@ -0,0 +1,119 @@
import { Drawing, CreateDrawingDto, DrawingRevision } from "@/types/drawing";
// Mock Data
const mockDrawings: Drawing[] = [
{
drawing_id: 1,
drawing_number: "A-101",
title: "Ground Floor Plan",
type: "CONTRACT",
discipline_id: 2,
discipline: { id: 2, discipline_code: "ARC", discipline_name: "Architecture" },
sheet_number: "01",
scale: "1:100",
current_revision: "0",
issue_date: new Date(Date.now() - 100000000).toISOString(),
revision_count: 1,
revisions: [
{
revision_id: 1,
revision_number: "0",
revision_date: new Date(Date.now() - 100000000).toISOString(),
revision_description: "Issued for Construction",
revised_by_name: "John Doe",
file_url: "/mock-drawing.pdf",
is_current: true,
},
],
},
{
drawing_id: 2,
drawing_number: "S-201",
title: "Foundation Details",
type: "SHOP",
discipline_id: 1,
discipline: { id: 1, discipline_code: "STR", discipline_name: "Structure" },
sheet_number: "05",
scale: "1:50",
current_revision: "B",
issue_date: new Date().toISOString(),
revision_count: 2,
revisions: [
{
revision_id: 3,
revision_number: "B",
revision_date: new Date().toISOString(),
revision_description: "Updated reinforcement",
revised_by_name: "Jane Smith",
file_url: "/mock-drawing-v2.pdf",
is_current: true,
},
{
revision_id: 2,
revision_number: "A",
revision_date: new Date(Date.now() - 50000000).toISOString(),
revision_description: "First Submission",
revised_by_name: "Jane Smith",
file_url: "/mock-drawing-v1.pdf",
is_current: false,
},
],
},
];
export const drawingApi = {
getAll: async (params?: { type?: "CONTRACT" | "SHOP"; search?: string }) => {
await new Promise((resolve) => setTimeout(resolve, 500));
let filtered = [...mockDrawings];
if (params?.type) {
filtered = filtered.filter((d) => d.type === params.type);
}
if (params?.search) {
const lowerSearch = params.search.toLowerCase();
filtered = filtered.filter((d) =>
d.drawing_number.toLowerCase().includes(lowerSearch) ||
d.title.toLowerCase().includes(lowerSearch)
);
}
return filtered;
},
getById: async (id: number) => {
await new Promise((resolve) => setTimeout(resolve, 500));
return mockDrawings.find((d) => d.drawing_id === id);
},
create: async (data: CreateDrawingDto) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const newId = Math.max(...mockDrawings.map((d) => d.drawing_id)) + 1;
const newDrawing: Drawing = {
drawing_id: newId,
drawing_number: data.drawing_number,
title: data.title,
type: data.drawing_type,
discipline_id: data.discipline_id,
discipline: { id: data.discipline_id, discipline_code: "MOCK", discipline_name: "Mock Discipline" },
sheet_number: data.sheet_number,
scale: data.scale,
current_revision: "0",
issue_date: new Date().toISOString(),
revision_count: 1,
revisions: [
{
revision_id: newId * 10,
revision_number: "0",
revision_date: new Date().toISOString(),
revision_description: "Initial Upload",
revised_by_name: "Current User",
file_url: "#",
is_current: true,
}
]
};
mockDrawings.unshift(newDrawing);
return newDrawing;
},
};

View File

@@ -0,0 +1,50 @@
import { NotificationResponse } from "@/types/notification";
// Mock Data
let mockNotifications = [
{
notification_id: 1,
title: "RFA Approved",
message: "RFA-001 has been approved by the Project Manager.",
type: "SUCCESS" as const,
is_read: false,
created_at: new Date(Date.now() - 1000 * 60 * 15).toISOString(), // 15 mins ago
link: "/rfas/1",
},
{
notification_id: 2,
title: "New Correspondence",
message: "You have received a new correspondence from Contractor A.",
type: "INFO" as const,
is_read: false,
created_at: new Date(Date.now() - 1000 * 60 * 60).toISOString(), // 1 hour ago
link: "/correspondences/3",
},
{
notification_id: 3,
title: "Drawing Revision Required",
message: "Drawing S-201 requires revision based on recent comments.",
type: "WARNING" as const,
is_read: true,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 1 day ago
link: "/drawings/2",
},
];
export const notificationApi = {
getUnread: async (): Promise<NotificationResponse> => {
await new Promise((resolve) => setTimeout(resolve, 300));
const unread = mockNotifications.filter((n) => !n.is_read);
return {
items: mockNotifications, // Return all for the list, but count unread
unreadCount: unread.length,
};
},
markAsRead: async (id: number) => {
await new Promise((resolve) => setTimeout(resolve, 200));
mockNotifications = mockNotifications.map((n) =>
n.notification_id === id ? { ...n, is_read: true } : n
);
},
};

View File

@@ -0,0 +1,111 @@
import { NumberingTemplate, NumberingSequence, CreateTemplateDto, TestGenerationResult } from "@/types/numbering";
// Mock Data
let 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,
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,
reset_annually: true,
padding_length: 4,
is_active: true,
updated_at: new Date(Date.now() - 86400000).toISOString(),
},
];
const mockSequences: NumberingSequence[] = [
{
sequence_id: 1,
template_id: 1,
year: 2025,
organization_code: "PAT",
current_number: 125,
last_generated_number: "PAT-CORR-2025-0125",
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",
updated_at: new Date().toISOString(),
},
];
export const numberingApi = {
getTemplates: async (): Promise<NumberingTemplate[]> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return [...mockTemplates];
},
getTemplate: async (id: number): Promise<NumberingTemplate | undefined> => {
await new Promise((resolve) => setTimeout(resolve, 300));
return mockTemplates.find((t) => t.template_id === id);
},
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;
},
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 (templateId: number): Promise<NumberingSequence[]> => {
await new Promise((resolve) => setTimeout(resolve, 400));
return mockSequences.filter((s) => s.template_id === templateId);
},
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");
// 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");
return { number };
},
};

98
frontend/lib/api/rfas.ts Normal file
View File

@@ -0,0 +1,98 @@
import { RFA, CreateRFADto, RFAItem } from "@/types/rfa";
// Mock Data
const mockRFAs: RFA[] = [
{
rfa_id: 1,
rfa_number: "LCBP3-RFA-001",
subject: "Approval for Concrete Mix Design",
description: "Requesting approval for the proposed concrete mix design for foundations.",
contract_id: 1,
discipline_id: 1,
status: "PENDING",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
contract_name: "Main Construction Contract",
discipline_name: "Civil",
items: [
{ id: 1, item_no: "1.1", description: "Concrete Mix Type A", quantity: 1, unit: "Lot", status: "PENDING" },
{ id: 2, item_no: "1.2", description: "Concrete Mix Type B", quantity: 1, unit: "Lot", status: "PENDING" },
],
},
{
rfa_id: 2,
rfa_number: "LCBP3-RFA-002",
subject: "Approval for Steel Reinforcement Shop Drawings",
description: "Shop drawings for Zone A foundations.",
contract_id: 1,
discipline_id: 2,
status: "APPROVED",
created_at: new Date(Date.now() - 172800000).toISOString(),
updated_at: new Date(Date.now() - 86400000).toISOString(),
contract_name: "Main Construction Contract",
discipline_name: "Structural",
items: [
{ id: 3, item_no: "1", description: "Shop Drawing Set A", quantity: 1, unit: "Set", status: "APPROVED" },
],
},
];
export const rfaApi = {
getAll: async (params?: { page?: number; status?: string; search?: string }) => {
await new Promise((resolve) => setTimeout(resolve, 500));
let filtered = [...mockRFAs];
if (params?.status) {
filtered = filtered.filter((r) => r.status === params.status);
}
if (params?.search) {
const lowerSearch = params.search.toLowerCase();
filtered = filtered.filter((r) =>
r.subject.toLowerCase().includes(lowerSearch) ||
r.rfa_number.toLowerCase().includes(lowerSearch)
);
}
return {
items: filtered,
total: filtered.length,
page: params?.page || 1,
totalPages: 1,
};
},
getById: async (id: number) => {
await new Promise((resolve) => setTimeout(resolve, 500));
return mockRFAs.find((r) => r.rfa_id === id);
},
create: async (data: CreateRFADto) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
const newId = Math.max(...mockRFAs.map((r) => r.rfa_id)) + 1;
const newRFA: RFA = {
rfa_id: newId,
rfa_number: `LCBP3-RFA-00${newId}`,
...data,
status: "DRAFT",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
contract_name: "Mock Contract",
discipline_name: "Mock Discipline",
items: data.items.map((item, index) => ({ ...item, id: index + 1, status: "PENDING" })),
};
mockRFAs.unshift(newRFA);
return newRFA;
},
updateStatus: async (id: number, status: RFA['status'], comments?: string) => {
await new Promise((resolve) => setTimeout(resolve, 800));
const rfa = mockRFAs.find((r) => r.rfa_id === id);
if (rfa) {
rfa.status = status;
rfa.updated_at = new Date().toISOString();
// In a real app, we'd log the comments and history
}
return rfa;
},
};

View File

@@ -0,0 +1,79 @@
import { SearchResult, SearchFilters } from "@/types/search";
// Mock Data
const mockResults: SearchResult[] = [
{
id: 1,
type: "correspondence",
title: "Submission of Monthly Report - Jan 2025",
description: "Please find attached the monthly progress report.",
status: "PENDING",
documentNumber: "LCBP3-COR-001",
createdAt: new Date().toISOString(),
highlight: "Submission of <b>Monthly Report</b> - Jan 2025",
},
{
id: 1,
type: "rfa",
title: "Approval for Concrete Mix Design",
description: "Requesting approval for the proposed concrete mix design.",
status: "PENDING",
documentNumber: "LCBP3-RFA-001",
createdAt: new Date().toISOString(),
highlight: "Approval for <b>Concrete Mix</b> Design",
},
{
id: 1,
type: "drawing",
title: "Ground Floor Plan",
description: "Architectural ground floor plan.",
status: "APPROVED",
documentNumber: "A-101",
createdAt: new Date(Date.now() - 100000000).toISOString(),
highlight: "Ground Floor <b>Plan</b>",
},
{
id: 2,
type: "correspondence",
title: "Request for Information regarding Foundation",
description: "Clarification needed on drawing A-101.",
status: "IN_REVIEW",
documentNumber: "LCBP3-COR-002",
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
export const searchApi = {
search: async (filters: SearchFilters) => {
await new Promise((resolve) => setTimeout(resolve, 600));
let results = [...mockResults];
if (filters.query) {
const lowerQuery = filters.query.toLowerCase();
results = results.filter((r) =>
r.title.toLowerCase().includes(lowerQuery) ||
r.documentNumber.toLowerCase().includes(lowerQuery) ||
r.description?.toLowerCase().includes(lowerQuery)
);
}
if (filters.types && filters.types.length > 0) {
results = results.filter((r) => filters.types?.includes(r.type));
}
if (filters.statuses && filters.statuses.length > 0) {
results = results.filter((r) => filters.statuses?.includes(r.status));
}
return results;
},
suggest: async (query: string) => {
await new Promise((resolve) => setTimeout(resolve, 300));
const lowerQuery = query.toLowerCase();
return mockResults
.filter((r) => r.title.toLowerCase().includes(lowerQuery))
.slice(0, 5);
},
};

View File

@@ -0,0 +1,84 @@
import { Workflow, CreateWorkflowDto, ValidationResult } from "@/types/workflow";
// Mock Data
let mockWorkflows: Workflow[] = [
{
workflow_id: 1,
workflow_name: "Standard RFA Workflow",
description: "Default approval process for RFAs",
workflow_type: "RFA",
version: 1,
is_active: true,
dsl_definition: `name: Standard RFA Workflow
steps:
- name: Review
type: REVIEW
role: CM
next: Approval
- name: Approval
type: APPROVAL
role: PM`,
step_count: 2,
updated_at: new Date().toISOString(),
},
{
workflow_id: 2,
workflow_name: "Correspondence Review",
description: "Incoming correspondence review flow",
workflow_type: "CORRESPONDENCE",
version: 2,
is_active: true,
dsl_definition: `name: Correspondence Review
steps:
- name: Initial Review
type: REVIEW
role: DC`,
step_count: 1,
updated_at: new Date(Date.now() - 86400000).toISOString(),
},
];
export const workflowApi = {
getWorkflows: async (): Promise<Workflow[]> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return [...mockWorkflows];
},
getWorkflow: async (id: number): Promise<Workflow | undefined> => {
await new Promise((resolve) => setTimeout(resolve, 300));
return mockWorkflows.find((w) => w.workflow_id === id);
},
createWorkflow: async (data: CreateWorkflowDto): Promise<Workflow> => {
await new Promise((resolve) => setTimeout(resolve, 800));
const newWorkflow: Workflow = {
workflow_id: Math.max(...mockWorkflows.map((w) => w.workflow_id)) + 1,
...data,
version: 1,
is_active: true,
step_count: 0, // Simplified for mock
updated_at: new Date().toISOString(),
};
mockWorkflows.push(newWorkflow);
return newWorkflow;
},
updateWorkflow: async (id: number, data: Partial<CreateWorkflowDto>): Promise<Workflow> => {
await new Promise((resolve) => setTimeout(resolve, 600));
const index = mockWorkflows.findIndex((w) => w.workflow_id === id);
if (index === -1) throw new Error("Workflow not found");
const updatedWorkflow = { ...mockWorkflows[index], ...data, updated_at: new Date().toISOString() };
mockWorkflows[index] = updatedWorkflow;
return updatedWorkflow;
},
validateDSL: async (dsl: string): Promise<ValidationResult> => {
await new Promise((resolve) => setTimeout(resolve, 400));
// Simple mock validation
if (!dsl.includes("name:") || !dsl.includes("steps:")) {
return { valid: false, errors: ["Missing 'name' or 'steps' field"] };
}
return { valid: true, errors: [] };
},
};

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -23,6 +24,7 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -32,7 +34,9 @@
"next-auth": "5.0.0-beta.30",
"react": "^18",
"react-dom": "^18",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"reactflow": "^11.11.4",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^13.0.0",

View File

@@ -20,7 +20,7 @@
],
"paths": {
"baseUrl": "./",
"@/*": ["app/*"],
"@/*": ["./*"],
"@components": ["components/*"],
"@config": ["config/*"],
"@lib": ["lib/*"],

43
frontend/types/admin.ts Normal file
View File

@@ -0,0 +1,43 @@
export interface Role {
role_id: number;
role_name: string;
description: string;
}
export interface User {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
is_active: boolean;
roles: Role[];
}
export interface CreateUserDto {
username: string;
email: string;
first_name: string;
last_name: string;
password?: string;
is_active: boolean;
roles: number[];
}
export interface Organization {
org_id: number;
org_code: string;
org_name: string;
org_name_th?: string;
description?: string;
}
export interface AuditLog {
audit_log_id: number;
user_name: string;
action: string;
entity_type: string;
description: string;
ip_address?: string;
created_at: string;
}

View File

@@ -0,0 +1,32 @@
export interface Organization {
id: number;
org_name: string;
org_code: string;
}
export interface Correspondence {
correspondence_id: number;
document_number: string;
subject: string;
description?: string;
status: "DRAFT" | "PENDING" | "IN_REVIEW" | "APPROVED" | "REJECTED" | "CLOSED";
importance: "NORMAL" | "HIGH" | "URGENT";
created_at: string;
updated_at: string;
from_organization_id: number;
to_organization_id: number;
from_organization?: Organization;
to_organization?: Organization;
document_type_id: number;
attachments?: any[]; // Define Attachment type if needed
}
export interface CreateCorrespondenceDto {
subject: string;
description?: string;
document_type_id: number;
from_organization_id: number;
to_organization_id: number;
importance: "NORMAL" | "HIGH" | "URGENT";
attachments?: File[];
}

View File

@@ -0,0 +1,28 @@
export interface DashboardStats {
correspondences: number;
rfas: number;
approved: number;
pending: number;
}
export interface ActivityLog {
id: number;
user: {
name: string;
initials: string;
avatar?: string;
};
action: string;
description: string;
createdAt: string;
targetUrl: string;
}
export interface PendingTask {
id: number;
title: string;
description: string;
daysOverdue: number;
url: string;
priority: "HIGH" | "MEDIUM" | "LOW";
}

34
frontend/types/drawing.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface DrawingRevision {
revision_id: number;
revision_number: string;
revision_date: string;
revision_description?: string;
revised_by_name: string;
file_url: string;
is_current: boolean;
}
export interface Drawing {
drawing_id: number;
drawing_number: string;
title: string;
type: "CONTRACT" | "SHOP";
discipline_id: number;
discipline?: { id: number; discipline_code: string; discipline_name: string };
sheet_number: string;
scale?: string;
current_revision: string;
issue_date: string;
revision_count: number;
revisions?: DrawingRevision[];
}
export interface CreateDrawingDto {
drawing_type: "CONTRACT" | "SHOP";
drawing_number: string;
title: string;
discipline_id: number;
sheet_number: string;
scale?: string;
file: File;
}

View File

@@ -0,0 +1,14 @@
export interface Notification {
notification_id: number;
title: string;
message: string;
type: "INFO" | "SUCCESS" | "WARNING" | "ERROR";
is_read: boolean;
created_at: string;
link?: string;
}
export interface NotificationResponse {
items: Notification[];
unreadCount: number;
}

View File

@@ -0,0 +1,37 @@
export interface NumberingTemplate {
template_id: number;
document_type_id: string;
document_type_name: string;
discipline_code?: string;
template_format: string;
example_number: string;
current_number: number;
reset_annually: boolean;
padding_length: number;
is_active: boolean;
updated_at: string;
}
export interface NumberingSequence {
sequence_id: number;
template_id: number;
year: number;
organization_code?: string;
discipline_code?: string;
current_number: number;
last_generated_number: string;
updated_at: string;
}
export interface CreateTemplateDto {
document_type_id: string;
discipline_code?: string;
template_format: string;
reset_annually: boolean;
padding_length: number;
starting_number: number;
}
export interface TestGenerationResult {
number: string;
}

32
frontend/types/rfa.ts Normal file
View File

@@ -0,0 +1,32 @@
export interface RFAItem {
id?: number;
item_no: string;
description: string;
quantity: number;
unit: string;
status?: "PENDING" | "APPROVED" | "REJECTED";
}
export interface RFA {
rfa_id: number;
rfa_number: string;
subject: string;
description?: string;
contract_id: number;
discipline_id: number;
status: "DRAFT" | "PENDING" | "IN_REVIEW" | "APPROVED" | "REJECTED" | "CLOSED";
created_at: string;
updated_at: string;
items: RFAItem[];
// Mock fields for display
contract_name?: string;
discipline_name?: string;
}
export interface CreateRFADto {
subject: string;
description?: string;
contract_id: number;
discipline_id: number;
items: RFAItem[];
}

18
frontend/types/search.ts Normal file
View File

@@ -0,0 +1,18 @@
export interface SearchResult {
id: number;
type: "correspondence" | "rfa" | "drawing";
title: string;
description?: string;
status: string;
documentNumber: string;
createdAt: string;
highlight?: string;
}
export interface SearchFilters {
query?: string;
types?: string[];
statuses?: string[];
dateFrom?: Date;
dateTo?: Date;
}

View File

@@ -0,0 +1,35 @@
export type WorkflowType = "CORRESPONDENCE" | "RFA" | "DRAWING";
export interface WorkflowStep {
step_id?: string;
step_name: string;
step_type: "APPROVAL" | "REVIEW" | "ENDORSEMENT";
approver_role_id?: number;
approver_role_name?: string;
next_step_success?: string;
next_step_failure?: string;
}
export interface Workflow {
workflow_id: number;
workflow_name: string;
description: string;
workflow_type: WorkflowType;
version: number;
is_active: boolean;
dsl_definition: string;
step_count: number;
updated_at: string;
}
export interface CreateWorkflowDto {
workflow_name: string;
description: string;
workflow_type: WorkflowType;
dsl_definition: string;
}
export interface ValidationResult {
valid: boolean;
errors: string[];
}