251211:1314 Frontend: reeactor Admin panel
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-11 13:14:15 +07:00
parent c8a0f281ef
commit 3fa28bd14f
79 changed files with 6571 additions and 206 deletions
@@ -1,70 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
ParseIntPipe,
Query,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { ContractService } from './contract.service.js';
import { CreateContractDto } from './dto/create-contract.dto.js';
import { UpdateContractDto } from './dto/update-contract.dto.js';
import { SearchContractDto } from './dto/search-contract.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
@ApiTags('Contracts')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('contracts')
export class ContractController {
constructor(private readonly contractService: ContractService) {}
@Post()
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create Contract' })
create(@Body() dto: CreateContractDto) {
return this.contractService.create(dto);
}
@Get()
@ApiOperation({
summary: 'Get All Contracts (Search & Filter)',
})
findAll(@Query() query: SearchContractDto) {
return this.contractService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get Contract by ID' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.contractService.findOne(id);
}
@Patch(':id')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update Contract' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateContractDto
) {
return this.contractService.update(id, dto);
}
@Delete(':id')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete Contract' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.contractService.remove(id);
}
}
@@ -1,99 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import { Contract } from './entities/contract.entity';
import { CreateContractDto } from './dto/create-contract.dto.js';
import { UpdateContractDto } from './dto/update-contract.dto.js';
@Injectable()
export class ContractService {
constructor(
@InjectRepository(Contract)
private readonly contractRepo: Repository<Contract>
) {}
async create(dto: CreateContractDto) {
const existing = await this.contractRepo.findOne({
where: { contractCode: dto.contractCode },
});
if (existing) {
throw new ConflictException(
`Contract Code "${dto.contractCode}" already exists`
);
}
const contract = this.contractRepo.create(dto);
return this.contractRepo.save(contract);
}
async findAll(params?: any) {
const { search, projectId, page = 1, limit = 100 } = params || {};
const skip = (page - 1) * limit;
const findOptions: any = {
relations: ['project'],
order: { contractCode: 'ASC' },
skip,
take: limit,
where: [],
};
const searchConditions = [];
if (search) {
searchConditions.push({ contractCode: Like(`%${search}%`) });
searchConditions.push({ contractName: Like(`%${search}%`) });
}
if (projectId) {
// Combine project filter with search if exists
if (searchConditions.length > 0) {
findOptions.where = searchConditions.map((cond) => ({
...cond,
projectId,
}));
} else {
findOptions.where = { projectId };
}
} else {
if (searchConditions.length > 0) {
findOptions.where = searchConditions;
} else {
delete findOptions.where; // No filters
}
}
const [data, total] = await this.contractRepo.findAndCount(findOptions);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: number) {
const contract = await this.contractRepo.findOne({
where: { id },
relations: ['project'],
});
if (!contract) throw new NotFoundException(`Contract ID ${id} not found`);
return contract;
}
async update(id: number, dto: UpdateContractDto) {
const contract = await this.findOne(id);
Object.assign(contract, dto);
return this.contractRepo.save(contract);
}
async remove(id: number) {
const contract = await this.findOne(id);
// Schema doesn't have deleted_at for Contract either.
return this.contractRepo.remove(contract);
}
}
@@ -1,49 +0,0 @@
import {
IsString,
IsNotEmpty,
IsBoolean,
IsOptional,
Length,
IsInt,
IsDateString,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateContractDto {
@ApiProperty({ example: 1 })
@IsInt()
@IsNotEmpty()
projectId!: number;
@ApiProperty({ example: 'C-001' })
@IsString()
@IsNotEmpty()
@Length(1, 50)
contractCode!: string;
@ApiProperty({ example: 'Main Construction Contract' })
@IsString()
@IsNotEmpty()
@Length(1, 255)
contractName!: string;
@ApiProperty({ example: 'Description of the contract', required: false })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: '2024-01-01', required: false })
@IsOptional()
@IsDateString()
startDate?: string; // Receive as string, TypeORM handles date
@ApiProperty({ example: '2025-12-31', required: false })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiProperty({ example: true, required: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -1,31 +0,0 @@
import {
IsString,
IsNotEmpty,
IsBoolean,
IsOptional,
Length,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateOrganizationDto {
@ApiProperty({ example: 'ITD' })
@IsString()
@IsNotEmpty()
@Length(1, 20)
organizationCode!: string;
@ApiProperty({ example: 'Italian-Thai Development' })
@IsString()
@IsNotEmpty()
@Length(1, 255)
organizationName!: string;
@ApiProperty({ example: 1, required: false })
@IsOptional()
roleId?: number;
@ApiProperty({ example: true, required: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -1,30 +0,0 @@
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchContractDto {
@ApiPropertyOptional({ description: 'Search term (code or name)' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'Filter by Project ID' })
@IsOptional()
@IsInt()
@Type(() => Number)
projectId?: number;
@ApiPropertyOptional({ description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number = 1;
@ApiPropertyOptional({ description: 'Items per page', default: 100 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
limit?: number = 100;
}
@@ -1,30 +0,0 @@
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchOrganizationDto {
@ApiPropertyOptional({ description: 'Search term (code or name)' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'Filter by Role ID' })
@IsOptional()
@IsInt()
@Type(() => Number)
roleId?: number;
@ApiPropertyOptional({ description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number = 1;
@ApiPropertyOptional({ description: 'Items per page', default: 100 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
limit?: number = 100;
}
@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContractDto } from './create-contract.dto.js';
export class UpdateContractDto extends PartialType(CreateContractDto) {}
@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/swagger';
import { CreateOrganizationDto } from './create-organization.dto.js';
export class UpdateOrganizationDto extends PartialType(CreateOrganizationDto) {}
@@ -1,25 +0,0 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Contract } from './contract.entity';
import { Organization } from './organization.entity';
@Entity('contract_organizations')
export class ContractOrganization {
@PrimaryColumn({ name: 'contract_id' })
contractId!: number;
@PrimaryColumn({ name: 'organization_id' })
organizationId!: number;
@Column({ name: 'role_in_contract', nullable: true, length: 100 })
roleInContract?: string;
// Relation ไปยัง Contract
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
// Relation ไปยัง Organization
@ManyToOne(() => Organization, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'organization_id' })
organization?: Organization;
}
@@ -1,41 +0,0 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Project } from './project.entity';
@Entity('contracts')
export class Contract extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'project_id' })
projectId!: number;
@Column({ name: 'contract_code', unique: true, length: 50 })
contractCode!: string;
@Column({ name: 'contract_name', length: 255 })
contractName!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate?: Date;
@Column({ name: 'end_date', type: 'date', nullable: true })
endDate?: Date;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
// Relation
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project?: Project;
}
@@ -1,20 +0,0 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity';
@Entity('organizations')
export class Organization extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'organization_code', unique: true, length: 20 })
organizationCode!: string;
@Column({ name: 'organization_name', length: 255 })
organizationName!: string;
@Column({ name: 'role_id', nullable: true })
roleId?: number;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
}
@@ -1,6 +1,6 @@
import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Project } from './project.entity';
import { Organization } from './organization.entity';
import { Organization } from '../../organization/entities/organization.entity';
@Entity('project_organizations')
export class ProjectOrganization {
@@ -1,6 +1,6 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Contract } from './contract.entity';
import { Contract } from '../../contract/entities/contract.entity';
@Entity('projects')
export class Project extends BaseEntity {
@@ -1,63 +0,0 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationService } from './organization.service.js';
import { CreateOrganizationDto } from './dto/create-organization.dto.js';
import { UpdateOrganizationDto } from './dto/update-organization.dto.js';
import { SearchOrganizationDto } from './dto/search-organization.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
@ApiTags('Organizations')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('organizations')
export class OrganizationController {
constructor(private readonly orgService: OrganizationService) {}
@Post()
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create Organization' })
create(@Body() dto: CreateOrganizationDto) {
return this.orgService.create(dto);
}
@Get()
@ApiOperation({ summary: 'Get All Organizations' })
findAll(@Query() query: SearchOrganizationDto) {
return this.orgService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get Organization by ID' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.orgService.findOne(id);
}
@Patch(':id')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update Organization' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateOrganizationDto
) {
return this.orgService.update(id, dto);
}
@Delete(':id')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete Organization' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.orgService.remove(id);
}
}
@@ -1,87 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import { Organization } from './entities/organization.entity';
import { CreateOrganizationDto } from './dto/create-organization.dto.js';
import { UpdateOrganizationDto } from './dto/update-organization.dto.js';
@Injectable()
export class OrganizationService {
constructor(
@InjectRepository(Organization)
private readonly orgRepo: Repository<Organization>
) {}
async create(dto: CreateOrganizationDto) {
const existing = await this.orgRepo.findOne({
where: { organizationCode: dto.organizationCode },
});
if (existing) {
throw new ConflictException(
`Organization Code "${dto.organizationCode}" already exists`
);
}
const org = this.orgRepo.create(dto);
return this.orgRepo.save(org);
}
async findAll(params?: any) {
const { search, page = 1, limit = 100 } = params || {};
const skip = (page - 1) * limit;
// Use findAndCount for safer, standard TypeORM queries
const findOptions: any = {
order: { organizationCode: 'ASC' },
skip,
take: limit,
};
if (search) {
findOptions.where = [
{ organizationCode: Like(`%${search}%`) },
{ organizationName: Like(`%${search}%`) },
];
}
// Debug logging
console.log(
'[OrganizationService] Finding all with options:',
JSON.stringify(findOptions)
);
const [data, total] = await this.orgRepo.findAndCount(findOptions);
console.log(`[OrganizationService] Found ${total} organizations`);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: number) {
const org = await this.orgRepo.findOne({ where: { id } });
if (!org) throw new NotFoundException(`Organization ID ${id} not found`);
return org;
}
async update(id: number, dto: UpdateOrganizationDto) {
const org = await this.findOne(id);
Object.assign(org, dto);
return this.orgRepo.save(org);
}
async remove(id: number) {
const org = await this.findOne(id);
// Hard delete or Soft delete? Schema doesn't have deleted_at for Organization, but let's check.
// Schema says: created_at, updated_at. No deleted_at.
// So hard delete.
return this.orgRepo.remove(org);
}
}
+6 -17
View File
@@ -2,32 +2,21 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProjectService } from './project.service.js';
import { ProjectController } from './project.controller.js';
import { OrganizationService } from './organization.service.js';
import { OrganizationController } from './organization.controller.js';
import { ContractService } from './contract.service.js';
import { ContractController } from './contract.controller.js';
import { Project } from './entities/project.entity';
import { Organization } from './entities/organization.entity';
import { Contract } from './entities/contract.entity';
import { ProjectOrganization } from './entities/project-organization.entity';
import { ContractOrganization } from './entities/contract-organization.entity';
// Modules
import { UserModule } from '../user/user.module';
import { OrganizationModule } from '../organization/organization.module';
@Module({
imports: [
TypeOrmModule.forFeature([
Project,
Organization,
Contract,
ProjectOrganization,
ContractOrganization,
]),
TypeOrmModule.forFeature([Project, ProjectOrganization]),
UserModule,
OrganizationModule,
],
controllers: [ProjectController, OrganizationController, ContractController],
providers: [ProjectService, OrganizationService, ContractService],
exports: [ProjectService, OrganizationService, ContractService],
controllers: [ProjectController],
providers: [ProjectService],
exports: [ProjectService],
})
export class ProjectModule {}
@@ -2,12 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ProjectService } from './project.service';
import { Project } from './entities/project.entity';
import { Organization } from './entities/organization.entity';
import { OrganizationService } from '../organization/organization.service';
describe('ProjectService', () => {
let service: ProjectService;
let mockProjectRepository: Record<string, jest.Mock>;
let mockOrganizationRepository: Record<string, jest.Mock>;
let mockOrganizationService: Record<string, jest.Mock>;
beforeEach(async () => {
mockProjectRepository = {
@@ -27,9 +27,8 @@ describe('ProjectService', () => {
})),
};
mockOrganizationRepository = {
find: jest.fn(),
findOne: jest.fn(),
mockOrganizationService = {
findAllActive: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
@@ -40,8 +39,8 @@ describe('ProjectService', () => {
useValue: mockProjectRepository,
},
{
provide: getRepositoryToken(Organization),
useValue: mockOrganizationRepository,
provide: OrganizationService,
useValue: mockOrganizationService,
},
],
}).compile();
@@ -66,7 +65,7 @@ describe('ProjectService', () => {
.createQueryBuilder()
.getManyAndCount.mockResolvedValue([mockProjects, 1]);
const result = await service.findAll({});
const result = await service.findAll({ page: 1, limit: 10 });
expect(result.data).toBeDefined();
expect(result.meta).toBeDefined();
@@ -76,11 +75,11 @@ describe('ProjectService', () => {
describe('findAllOrganizations', () => {
it('should return all organizations', async () => {
const mockOrgs = [{ organization_id: 1, name: 'Test Org' }];
mockOrganizationRepository.find.mockResolvedValue(mockOrgs);
mockOrganizationService.findAllActive.mockResolvedValue(mockOrgs);
const result = await service.findAllOrganizations();
expect(mockOrganizationRepository.find).toHaveBeenCalled();
expect(mockOrganizationService.findAllActive).toHaveBeenCalled();
expect(result).toEqual(mockOrgs);
});
});
@@ -9,7 +9,7 @@ import { Repository, Like } from 'typeorm';
// Entities
import { Project } from './entities/project.entity';
import { Organization } from './entities/organization.entity';
import { OrganizationService } from '../organization/organization.service';
// DTOs
import { CreateProjectDto } from './dto/create-project.dto.js';
@@ -23,8 +23,7 @@ export class ProjectService {
constructor(
@InjectRepository(Project)
private projectRepository: Repository<Project>,
@InjectRepository(Organization)
private organizationRepository: Repository<Organization>
private organizationService: OrganizationService
) {}
// --- CRUD Operations ---
@@ -123,9 +122,6 @@ export class ProjectService {
// --- Organization Helper ---
async findAllOrganizations() {
return this.organizationRepository.find({
where: { isActive: true },
order: { organizationCode: 'ASC' },
});
return this.organizationService.findAllActive();
}
}