251205:0000 Just start debug backend/frontend
This commit is contained in:
18
backend/src/config/database.config.ts
Normal file
18
backend/src/config/database.config.ts
Normal 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,
|
||||
};
|
||||
136
backend/src/database/migrations/InitialSchema.ts
Normal file
136
backend/src/database/migrations/InitialSchema.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
72
backend/src/database/seeds/organization.seed.ts
Normal file
72
backend/src/database/seeds/organization.seed.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
106
backend/src/database/seeds/user.seed.ts
Normal file
106
backend/src/database/seeds/user.seed.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
backend/src/modules/auth/entities/role.entity.ts
Normal file
45
backend/src/modules/auth/entities/role.entity.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
44
backend/src/modules/drawings/entities/drawing.entity.ts
Normal file
44
backend/src/modules/drawings/entities/drawing.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
41
backend/src/modules/rfas/entities/rfa.entity.ts
Normal file
41
backend/src/modules/rfas/entities/rfa.entity.ts
Normal 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;
|
||||
}
|
||||
52
backend/src/modules/users/entities/user.entity.ts
Normal file
52
backend/src/modules/users/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
122
frontend/app/(admin)/admin/audit-logs/page.tsx
Normal file
122
frontend/app/(admin)/admin/audit-logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx
Normal file
82
frontend/app/(admin)/admin/numbering/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/app/(admin)/admin/numbering/new/page.tsx
Normal file
27
frontend/app/(admin)/admin/numbering/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
frontend/app/(admin)/admin/numbering/page.tsx
Normal file
136
frontend/app/(admin)/admin/numbering/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
frontend/app/(admin)/admin/organizations/page.tsx
Normal file
156
frontend/app/(admin)/admin/organizations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
frontend/app/(admin)/admin/users/page.tsx
Normal file
143
frontend/app/(admin)/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
frontend/app/(admin)/admin/workflows/[id]/edit/page.tsx
Normal file
164
frontend/app/(admin)/admin/workflows/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
frontend/app/(admin)/admin/workflows/new/page.tsx
Normal file
134
frontend/app/(admin)/admin/workflows/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
frontend/app/(admin)/admin/workflows/page.tsx
Normal file
104
frontend/app/(admin)/admin/workflows/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/app/(admin)/layout.tsx
Normal file
30
frontend/app/(admin)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/app/(dashboard)/correspondences/[id]/page.tsx
Normal file
22
frontend/app/(dashboard)/correspondences/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
112
frontend/app/(dashboard)/drawings/[id]/page.tsx
Normal file
112
frontend/app/(dashboard)/drawings/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/app/(dashboard)/drawings/page.tsx
Normal file
43
frontend/app/(dashboard)/drawings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/app/(dashboard)/drawings/upload/page.tsx
Normal file
16
frontend/app/(dashboard)/drawings/upload/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
22
frontend/app/(dashboard)/rfas/[id]/page.tsx
Normal file
22
frontend/app/(dashboard)/rfas/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
16
frontend/app/(dashboard)/rfas/new/page.tsx
Normal file
16
frontend/app/(dashboard)/rfas/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/app/(dashboard)/rfas/page.tsx
Normal file
48
frontend/app/(dashboard)/rfas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/app/(dashboard)/search/page.tsx
Normal file
56
frontend/app/(dashboard)/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
48
frontend/components/admin/sidebar.tsx
Normal file
48
frontend/components/admin/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
frontend/components/admin/user-dialog.tsx
Normal file
240
frontend/components/admin/user-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
0
frontend/components/common/can.tsx
Normal file
0
frontend/components/common/can.tsx
Normal file
49
frontend/components/common/confirm-dialog.tsx
Normal file
49
frontend/components/common/confirm-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/components/common/data-table.tsx
Normal file
87
frontend/components/common/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
frontend/components/common/file-upload.tsx
Normal file
102
frontend/components/common/file-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/components/common/pagination.tsx
Normal file
74
frontend/components/common/pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/components/common/status-badge.tsx
Normal file
63
frontend/components/common/status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
frontend/components/correspondences/detail.tsx
Normal file
133
frontend/components/correspondences/detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/components/correspondences/form.tsx
Normal file
187
frontend/components/correspondences/form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/components/correspondences/list.tsx
Normal file
87
frontend/components/correspondences/list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/components/dashboard/pending-tasks.tsx
Normal file
66
frontend/components/dashboard/pending-tasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/components/dashboard/quick-actions.tsx
Normal file
30
frontend/components/dashboard/quick-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
64
frontend/components/dashboard/stats-cards.tsx
Normal file
64
frontend/components/dashboard/stats-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
frontend/components/drawings/card.tsx
Normal file
72
frontend/components/drawings/card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/components/drawings/list.tsx
Normal file
56
frontend/components/drawings/list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/components/drawings/revision-history.tsx
Normal file
50
frontend/components/drawings/revision-history.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
frontend/components/drawings/upload-form.tsx
Normal file
167
frontend/components/drawings/upload-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/components/layout/global-search.tsx
Normal file
114
frontend/components/layout/global-search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
frontend/components/layout/header.tsx
Normal file
24
frontend/components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
frontend/components/layout/notifications-dropdown.tsx
Normal file
139
frontend/components/layout/notifications-dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
81
frontend/components/layout/user-menu.tsx
Normal file
81
frontend/components/layout/user-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
frontend/components/numbering/sequence-viewer.tsx
Normal file
92
frontend/components/numbering/sequence-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
frontend/components/numbering/template-editor.tsx
Normal file
203
frontend/components/numbering/template-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/components/numbering/template-tester.tsx
Normal file
110
frontend/components/numbering/template-tester.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/components/rfas/detail.tsx
Normal file
201
frontend/components/rfas/detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
frontend/components/rfas/form.tsx
Normal file
237
frontend/components/rfas/form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
frontend/components/rfas/list.tsx
Normal file
80
frontend/components/rfas/list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/components/search/filters.tsx
Normal file
95
frontend/components/search/filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/components/search/results.tsx
Normal file
97
frontend/components/search/results.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
frontend/components/workflows/dsl-editor.tsx
Normal file
115
frontend/components/workflows/dsl-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend/components/workflows/visual-builder.tsx
Normal file
109
frontend/components/workflows/visual-builder.tsx
Normal 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
103
frontend/lib/api/admin.ts
Normal 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];
|
||||
},
|
||||
};
|
||||
85
frontend/lib/api/correspondences.ts
Normal file
85
frontend/lib/api/correspondences.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
65
frontend/lib/api/dashboard.ts
Normal file
65
frontend/lib/api/dashboard.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
119
frontend/lib/api/drawings.ts
Normal file
119
frontend/lib/api/drawings.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
50
frontend/lib/api/notifications.ts
Normal file
50
frontend/lib/api/notifications.ts
Normal 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
|
||||
);
|
||||
},
|
||||
};
|
||||
111
frontend/lib/api/numbering.ts
Normal file
111
frontend/lib/api/numbering.ts
Normal 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
98
frontend/lib/api/rfas.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
79
frontend/lib/api/search.ts
Normal file
79
frontend/lib/api/search.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
84
frontend/lib/api/workflows.ts
Normal file
84
frontend/lib/api/workflows.ts
Normal 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: [] };
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
],
|
||||
"paths": {
|
||||
"baseUrl": "./",
|
||||
"@/*": ["app/*"],
|
||||
"@/*": ["./*"],
|
||||
"@components": ["components/*"],
|
||||
"@config": ["config/*"],
|
||||
"@lib": ["lib/*"],
|
||||
|
||||
43
frontend/types/admin.ts
Normal file
43
frontend/types/admin.ts
Normal 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;
|
||||
}
|
||||
32
frontend/types/correspondence.ts
Normal file
32
frontend/types/correspondence.ts
Normal 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[];
|
||||
}
|
||||
28
frontend/types/dashboard.ts
Normal file
28
frontend/types/dashboard.ts
Normal 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
34
frontend/types/drawing.ts
Normal 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;
|
||||
}
|
||||
14
frontend/types/notification.ts
Normal file
14
frontend/types/notification.ts
Normal 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;
|
||||
}
|
||||
37
frontend/types/numbering.ts
Normal file
37
frontend/types/numbering.ts
Normal 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
32
frontend/types/rfa.ts
Normal 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
18
frontend/types/search.ts
Normal 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;
|
||||
}
|
||||
35
frontend/types/workflow.ts
Normal file
35
frontend/types/workflow.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user