feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
211
frontend/services/__tests__/onboarding.service.test.ts
Normal file
211
frontend/services/__tests__/onboarding.service.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import axios from 'axios'
|
||||
import { onboardingService } from '../onboarding.service'
|
||||
import type { OnboardingRequest, OnboardingResponse, EmployeeStatusResponse } from '../../types/onboarding'
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
const mockedAxios = axios as unknown as {
|
||||
post: ReturnType<typeof vi.fn>
|
||||
get: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('OnboardingService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('onboardEmployee', () => {
|
||||
it('should successfully onboard an employee', async () => {
|
||||
// Arrange
|
||||
const request: OnboardingRequest = {
|
||||
resume_id: 1,
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
hire_date: '2026-02-21',
|
||||
departments: [
|
||||
{
|
||||
department_id: 9,
|
||||
position: '資深工程師',
|
||||
membership_type: 'permanent',
|
||||
},
|
||||
],
|
||||
role_ids: [1, 2],
|
||||
storage_quota_gb: 20,
|
||||
email_quota_mb: 5120,
|
||||
}
|
||||
|
||||
const mockResponse: OnboardingResponse = {
|
||||
message: 'Employee onboarded successfully',
|
||||
employee: {
|
||||
tenant_id: 1,
|
||||
seq_no: 1,
|
||||
tenant_emp_code: 'PWD0001',
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
name: '王明',
|
||||
hire_date: '2026-02-21',
|
||||
},
|
||||
summary: {
|
||||
departments_assigned: 1,
|
||||
roles_assigned: 2,
|
||||
services_enabled: 5,
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
|
||||
|
||||
// Act
|
||||
const result = await onboardingService.onboardEmployee(request)
|
||||
|
||||
// Assert
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'http://localhost:10181/api/v1/emp-lifecycle/onboard',
|
||||
request
|
||||
)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should throw error when API fails', async () => {
|
||||
// Arrange
|
||||
const request: OnboardingRequest = {
|
||||
resume_id: 999,
|
||||
keycloak_user_id: 'invalid-uuid',
|
||||
keycloak_username: 'test',
|
||||
hire_date: '2026-02-21',
|
||||
departments: [],
|
||||
role_ids: [],
|
||||
}
|
||||
|
||||
const errorMessage = 'Resume ID 999 not found'
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: errorMessage },
|
||||
status: 404,
|
||||
},
|
||||
})
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
onboardingService.onboardEmployee(request)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEmployeeStatus', () => {
|
||||
it('should fetch employee status successfully', async () => {
|
||||
// Arrange
|
||||
const tenantId = 1
|
||||
const seqNo = 1
|
||||
|
||||
const mockResponse: EmployeeStatusResponse = {
|
||||
employee: {
|
||||
tenant_id: 1,
|
||||
seq_no: 1,
|
||||
tenant_emp_code: 'PWD0001',
|
||||
name: '王明',
|
||||
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloak_username: 'wang.ming',
|
||||
hire_date: '2026-02-21',
|
||||
resign_date: null,
|
||||
employment_status: 'active',
|
||||
storage_quota_gb: 20,
|
||||
email_quota_mb: 5120,
|
||||
},
|
||||
departments: [
|
||||
{
|
||||
department_id: 9,
|
||||
department_name: '玄鐵風能',
|
||||
position: '資深工程師',
|
||||
membership_type: 'permanent',
|
||||
joined_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
],
|
||||
roles: [
|
||||
{
|
||||
role_id: 1,
|
||||
role_name: 'HR管理員',
|
||||
role_code: 'HR_ADMIN',
|
||||
assigned_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
],
|
||||
services: [
|
||||
{
|
||||
service_id: 1,
|
||||
service_name: '單一簽入',
|
||||
service_code: 'SSO',
|
||||
quota_gb: null,
|
||||
quota_mb: null,
|
||||
enabled_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValueOnce({ data: mockResponse })
|
||||
|
||||
// Act
|
||||
const result = await onboardingService.getEmployeeStatus(tenantId, seqNo)
|
||||
|
||||
// Assert
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
`http://localhost:10181/api/v1/emp-lifecycle/${tenantId}/${seqNo}/status`
|
||||
)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should throw error when employee not found', async () => {
|
||||
// Arrange
|
||||
const tenantId = 1
|
||||
const seqNo = 999
|
||||
|
||||
mockedAxios.get.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: 'Employee not found' },
|
||||
status: 404,
|
||||
},
|
||||
})
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
onboardingService.getEmployeeStatus(tenantId, seqNo)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('offboardEmployee', () => {
|
||||
it('should successfully offboard an employee', async () => {
|
||||
// Arrange
|
||||
const tenantId = 1
|
||||
const seqNo = 1
|
||||
|
||||
const mockResponse = {
|
||||
message: 'Employee offboarded successfully',
|
||||
employee: {
|
||||
tenant_emp_code: 'PWD0001',
|
||||
resign_date: '2026-02-21',
|
||||
},
|
||||
summary: {
|
||||
departments_removed: 1,
|
||||
roles_revoked: 2,
|
||||
services_disabled: 5,
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
|
||||
|
||||
// Act
|
||||
const result = await onboardingService.offboardEmployee(tenantId, seqNo)
|
||||
|
||||
// Assert
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
`http://localhost:10181/api/v1/emp-lifecycle/${tenantId}/${seqNo}/offboard`
|
||||
)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
141
frontend/services/__tests__/tenant.service.test.ts
Normal file
141
frontend/services/__tests__/tenant.service.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import axios from 'axios'
|
||||
import { tenantService } from '../tenant.service'
|
||||
import type { Tenant, TenantUpdateRequest, TenantUpdateResponse } from '../../types/tenant'
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedAxios = axios as unknown as {
|
||||
get: ReturnType<typeof vi.fn>
|
||||
put: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('TenantService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getTenant', () => {
|
||||
it('should fetch tenant information successfully', async () => {
|
||||
// Arrange
|
||||
const tenantId = 1
|
||||
const mockTenant: Tenant = {
|
||||
id: 1,
|
||||
code: 'PWD',
|
||||
name: 'Porsche World',
|
||||
name_eng: 'Porsche World Co., Ltd.',
|
||||
tax_id: '12345678',
|
||||
prefix: 'PWD',
|
||||
tel: '02-1234-5678',
|
||||
add: '台北市信義區...',
|
||||
url: 'https://porscheworld.tw',
|
||||
plan_id: 'starter',
|
||||
max_users: 50,
|
||||
storage_quota_gb: 1000,
|
||||
status: 'active',
|
||||
is_sysmana: false,
|
||||
is_active: true,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
updated_at: '2026-02-21T00:00:00',
|
||||
}
|
||||
|
||||
mockedAxios.get.mockResolvedValueOnce({ data: mockTenant })
|
||||
|
||||
// Act
|
||||
const result = await tenantService.getTenant(tenantId)
|
||||
|
||||
// Assert
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
`http://localhost:10181/api/v1/tenants/${tenantId}`
|
||||
)
|
||||
expect(result).toEqual(mockTenant)
|
||||
})
|
||||
|
||||
it('should throw error when tenant not found', async () => {
|
||||
// Arrange
|
||||
const tenantId = 999
|
||||
mockedAxios.get.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: 'Tenant not found' },
|
||||
status: 404,
|
||||
},
|
||||
})
|
||||
|
||||
// Act & Assert
|
||||
await expect(tenantService.getTenant(tenantId)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTenant', () => {
|
||||
it('should update tenant information successfully', async () => {
|
||||
// Arrange
|
||||
const tenantId = 1
|
||||
const updateData: TenantUpdateRequest = {
|
||||
name: 'Porsche World Updated',
|
||||
tax_id: '87654321',
|
||||
tel: '02-8765-4321',
|
||||
add: '台北市大安區...',
|
||||
url: 'https://newporscheworld.tw',
|
||||
}
|
||||
|
||||
const mockResponse: TenantUpdateResponse = {
|
||||
message: 'Tenant updated successfully',
|
||||
tenant: {
|
||||
id: 1,
|
||||
code: 'PWD',
|
||||
name: 'Porsche World Updated',
|
||||
name_eng: 'Porsche World Co., Ltd.',
|
||||
tax_id: '87654321',
|
||||
prefix: 'PWD',
|
||||
tel: '02-8765-4321',
|
||||
add: '台北市大安區...',
|
||||
url: 'https://newporscheworld.tw',
|
||||
plan_id: 'starter',
|
||||
max_users: 50,
|
||||
storage_quota_gb: 1000,
|
||||
status: 'active',
|
||||
is_sysmana: false,
|
||||
is_active: true,
|
||||
created_at: '2026-01-01T00:00:00',
|
||||
updated_at: '2026-02-21T10:00:00',
|
||||
},
|
||||
}
|
||||
|
||||
mockedAxios.put.mockResolvedValueOnce({ data: mockResponse })
|
||||
|
||||
// Act
|
||||
const result = await tenantService.updateTenant(tenantId, updateData)
|
||||
|
||||
// Assert
|
||||
expect(mockedAxios.put).toHaveBeenCalledWith(
|
||||
`http://localhost:10181/api/v1/tenants/${tenantId}`,
|
||||
updateData
|
||||
)
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should throw error when update fails', async () => {
|
||||
// Arrange
|
||||
const tenantId = 1
|
||||
const updateData: TenantUpdateRequest = {
|
||||
name: 'Test',
|
||||
}
|
||||
|
||||
mockedAxios.put.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { detail: 'Update failed' },
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
|
||||
// Act & Assert
|
||||
await expect(tenantService.updateTenant(tenantId, updateData)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
47
frontend/services/business-units.ts
Normal file
47
frontend/services/business-units.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 事業單位管理 API 服務
|
||||
*/
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import type { BusinessUnit, PaginatedResponse } from '@/types'
|
||||
|
||||
export const businessUnitsService = {
|
||||
/**
|
||||
* 取得事業單位列表
|
||||
*/
|
||||
getBusinessUnits: async (params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
department_id?: string
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<BusinessUnit>> => {
|
||||
return apiClient.get('/business-units', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得單一事業單位詳情
|
||||
*/
|
||||
getBusinessUnit: async (id: string): Promise<BusinessUnit> => {
|
||||
return apiClient.get(`/business-units/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 建立事業單位
|
||||
*/
|
||||
createBusinessUnit: async (data: Partial<BusinessUnit>): Promise<BusinessUnit> => {
|
||||
return apiClient.post('/business-units', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新事業單位資料
|
||||
*/
|
||||
updateBusinessUnit: async (id: string, data: Partial<BusinessUnit>): Promise<BusinessUnit> => {
|
||||
return apiClient.put(`/business-units/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除事業單位
|
||||
*/
|
||||
deleteBusinessUnit: async (id: string): Promise<void> => {
|
||||
return apiClient.delete(`/business-units/${id}`)
|
||||
},
|
||||
}
|
||||
46
frontend/services/departments.ts
Normal file
46
frontend/services/departments.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 部門管理 API 服務
|
||||
*/
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import type { Department, PaginatedResponse } from '@/types'
|
||||
|
||||
export const departmentsService = {
|
||||
/**
|
||||
* 取得部門列表
|
||||
*/
|
||||
getDepartments: async (params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<Department>> => {
|
||||
return apiClient.get('/departments', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得單一部門詳情
|
||||
*/
|
||||
getDepartment: async (id: string): Promise<Department> => {
|
||||
return apiClient.get(`/departments/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 建立部門
|
||||
*/
|
||||
createDepartment: async (data: Partial<Department>): Promise<Department> => {
|
||||
return apiClient.post('/departments', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新部門資料
|
||||
*/
|
||||
updateDepartment: async (id: string, data: Partial<Department>): Promise<Department> => {
|
||||
return apiClient.put(`/departments/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除部門
|
||||
*/
|
||||
deleteDepartment: async (id: string): Promise<void> => {
|
||||
return apiClient.delete(`/departments/${id}`)
|
||||
},
|
||||
}
|
||||
73
frontend/services/email-accounts.ts
Normal file
73
frontend/services/email-accounts.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 郵件帳號服務
|
||||
* 處理所有與郵件帳號相關的 API 請求
|
||||
*/
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import type {
|
||||
EmailAccount,
|
||||
CreateEmailAccountInput,
|
||||
UpdateEmailAccountInput,
|
||||
EmailAccountQuotaUpdate,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export interface EmailAccountListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
employee_id?: number
|
||||
is_active?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export const emailAccountsService = {
|
||||
/**
|
||||
* 獲取郵件帳號列表
|
||||
*/
|
||||
async list(params?: EmailAccountListParams): Promise<PaginatedResponse<EmailAccount>> {
|
||||
return apiClient.get<PaginatedResponse<EmailAccount>>('/email-accounts', {
|
||||
params,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取郵件帳號詳情
|
||||
*/
|
||||
async get(id: number): Promise<EmailAccount> {
|
||||
return apiClient.get<EmailAccount>(`/email-accounts/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 創建郵件帳號
|
||||
*/
|
||||
async create(data: CreateEmailAccountInput): Promise<EmailAccount> {
|
||||
return apiClient.post<EmailAccount>('/email-accounts', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新郵件帳號
|
||||
*/
|
||||
async update(id: number, data: UpdateEmailAccountInput): Promise<EmailAccount> {
|
||||
return apiClient.put<EmailAccount>(`/email-accounts/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新郵件配額
|
||||
*/
|
||||
async updateQuota(id: number, data: EmailAccountQuotaUpdate): Promise<EmailAccount> {
|
||||
return apiClient.patch<EmailAccount>(`/email-accounts/${id}/quota`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 停用郵件帳號 (軟刪除)
|
||||
*/
|
||||
async delete(id: number): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`/email-accounts/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取員工的所有郵件帳號
|
||||
*/
|
||||
async getByEmployee(employeeId: number): Promise<EmailAccount[]> {
|
||||
return apiClient.get<EmailAccount[]>(`/email-accounts/employees/${employeeId}/email-accounts`)
|
||||
},
|
||||
}
|
||||
75
frontend/services/employees.ts
Normal file
75
frontend/services/employees.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 員工管理 API 服務
|
||||
*/
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import type {
|
||||
Employee,
|
||||
CreateEmployeeInput,
|
||||
UpdateEmployeeInput,
|
||||
PaginatedResponse,
|
||||
EmailAccount,
|
||||
} from '@/types'
|
||||
|
||||
export const employeesService = {
|
||||
/**
|
||||
* 取得員工列表
|
||||
*/
|
||||
getEmployees: async (params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
department_id?: string
|
||||
search?: string
|
||||
}): Promise<PaginatedResponse<Employee>> => {
|
||||
return apiClient.get('/employees', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得單一員工詳情
|
||||
*/
|
||||
getEmployee: async (id: string): Promise<Employee> => {
|
||||
return apiClient.get(`/employees/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 建立員工
|
||||
*/
|
||||
createEmployee: async (data: CreateEmployeeInput): Promise<Employee> => {
|
||||
return apiClient.post('/employees', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新員工資料
|
||||
*/
|
||||
updateEmployee: async (id: string, data: UpdateEmployeeInput): Promise<Employee> => {
|
||||
return apiClient.put(`/employees/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除員工
|
||||
*/
|
||||
deleteEmployee: async (id: string): Promise<void> => {
|
||||
return apiClient.delete(`/employees/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 取得員工的郵件帳號列表 (符合 WebMail 設計規範)
|
||||
*/
|
||||
getEmployeeEmailAccounts: async (userId: string): Promise<{ user_id: string; email_accounts: EmailAccount[] }> => {
|
||||
return apiClient.get(`/employees/${userId}/email-accounts`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 員工離職處理
|
||||
*/
|
||||
resignEmployee: async (id: string, resignationDate: string): Promise<Employee> => {
|
||||
return apiClient.post(`/employees/${id}/resign`, { resignation_date: resignationDate })
|
||||
},
|
||||
|
||||
/**
|
||||
* 員工復職處理
|
||||
*/
|
||||
reactivateEmployee: async (id: string): Promise<Employee> => {
|
||||
return apiClient.post(`/employees/${id}/reactivate`)
|
||||
},
|
||||
}
|
||||
46
frontend/services/onboarding.service.ts
Normal file
46
frontend/services/onboarding.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
OnboardingRequest,
|
||||
OnboardingResponse,
|
||||
EmployeeStatusResponse,
|
||||
} from '../types/onboarding'
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:10181'
|
||||
|
||||
class OnboardingService {
|
||||
/**
|
||||
* 員工到職
|
||||
*/
|
||||
async onboardEmployee(request: OnboardingRequest): Promise<OnboardingResponse> {
|
||||
const response = await axios.post<OnboardingResponse>(
|
||||
`${API_BASE_URL}/api/v1/emp-lifecycle/onboard`,
|
||||
request
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 查詢員工狀態
|
||||
*/
|
||||
async getEmployeeStatus(
|
||||
tenantId: number,
|
||||
seqNo: number
|
||||
): Promise<EmployeeStatusResponse> {
|
||||
const response = await axios.get<EmployeeStatusResponse>(
|
||||
`${API_BASE_URL}/api/v1/emp-lifecycle/${tenantId}/${seqNo}/status`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 員工離職
|
||||
*/
|
||||
async offboardEmployee(tenantId: number, seqNo: number) {
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/api/v1/emp-lifecycle/${tenantId}/${seqNo}/offboard`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
export const onboardingService = new OnboardingService()
|
||||
83
frontend/services/permissions.ts
Normal file
83
frontend/services/permissions.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 系統權限服務
|
||||
* 處理所有與系統權限相關的 API 請求
|
||||
*/
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import type {
|
||||
Permission,
|
||||
CreatePermissionInput,
|
||||
UpdatePermissionInput,
|
||||
PermissionBatchCreateInput,
|
||||
SystemInfo,
|
||||
PaginatedResponse,
|
||||
SystemName,
|
||||
AccessLevel,
|
||||
} from '@/types'
|
||||
|
||||
export interface PermissionListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
employee_id?: number
|
||||
system_name?: SystemName
|
||||
access_level?: AccessLevel
|
||||
}
|
||||
|
||||
export const permissionsService = {
|
||||
/**
|
||||
* 獲取權限列表
|
||||
*/
|
||||
async list(params?: PermissionListParams): Promise<PaginatedResponse<Permission>> {
|
||||
return apiClient.get<PaginatedResponse<Permission>>('/permissions', {
|
||||
params,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取權限詳情
|
||||
*/
|
||||
async get(id: number): Promise<Permission> {
|
||||
return apiClient.get<Permission>(`/permissions/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 創建權限
|
||||
*/
|
||||
async create(data: CreatePermissionInput): Promise<Permission> {
|
||||
return apiClient.post<Permission>('/permissions', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新權限
|
||||
*/
|
||||
async update(id: number, data: UpdatePermissionInput): Promise<Permission> {
|
||||
return apiClient.put<Permission>(`/permissions/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 刪除權限
|
||||
*/
|
||||
async delete(id: number): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`/permissions/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取員工的所有系統權限
|
||||
*/
|
||||
async getByEmployee(employeeId: number): Promise<Permission[]> {
|
||||
return apiClient.get<Permission[]>(`/permissions/employees/${employeeId}/permissions`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量創建權限
|
||||
*/
|
||||
async batchCreate(data: PermissionBatchCreateInput): Promise<Permission[]> {
|
||||
return apiClient.post<Permission[]>('/permissions/batch', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 獲取可授權的系統列表
|
||||
*/
|
||||
async getSystems(): Promise<SystemInfo> {
|
||||
return apiClient.get<SystemInfo>('/permissions/systems')
|
||||
},
|
||||
}
|
||||
73
frontend/services/systemFunction.service.ts
Normal file
73
frontend/services/systemFunction.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* System Function Service
|
||||
* 系統功能列表服務
|
||||
*/
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:10181';
|
||||
|
||||
export interface SystemFunctionNode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
function_type: number; // 1=NODE, 2=FUNCTION
|
||||
order: number;
|
||||
function_icon: string;
|
||||
module_code: string | null;
|
||||
module_functions: string[]; // ["View", "Create", "Read", "Update", "Delete"]
|
||||
description: string;
|
||||
children: SystemFunctionNode[];
|
||||
}
|
||||
|
||||
export const systemFunctionService = {
|
||||
/**
|
||||
* 取得功能列表樹狀結構
|
||||
* @param isSysmana 是否為系統管理公司
|
||||
* @returns Promise<SystemFunctionNode[]>
|
||||
*/
|
||||
async getMenuTree(isSysmana: boolean = false): Promise<SystemFunctionNode[]> {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/v1/system-functions/menu/tree?is_sysmana=${isSysmana}`;
|
||||
console.log('[SystemFunctionService] Fetching menu tree:', url);
|
||||
console.log('[SystemFunctionService] is_sysmana parameter:', isSysmana);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // 包含 cookies (用於認證)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch menu tree: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[SystemFunctionService] Received menu items:', data.length);
|
||||
console.log('[SystemFunctionService] Menu data:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching menu tree:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 將 code 轉換為路由路徑
|
||||
* @param code 功能代碼 (例如: tenant_departments)
|
||||
* @returns 路由路徑 (例如: /tenant-departments)
|
||||
*/
|
||||
codeToRoute(code: string): string {
|
||||
return `/${code.replace(/_/g, '-')}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 檢查功能是否有特定操作權限
|
||||
* @param moduleFunctions 功能操作列表
|
||||
* @param operation 操作名稱 (View, Create, Read, Update, Delete)
|
||||
* @returns boolean
|
||||
*/
|
||||
hasOperation(moduleFunctions: string[], operation: string): boolean {
|
||||
return moduleFunctions.includes(operation);
|
||||
},
|
||||
};
|
||||
60
frontend/services/tenant.service.ts
Normal file
60
frontend/services/tenant.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
Tenant,
|
||||
TenantUpdateRequest,
|
||||
TenantUpdateResponse,
|
||||
TenantListResponse,
|
||||
TenantCreateRequest,
|
||||
TenantCreateResponse,
|
||||
} from '../types/tenant'
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:10181'
|
||||
|
||||
class TenantService {
|
||||
/**
|
||||
* Get all tenants
|
||||
*/
|
||||
async getTenants(): Promise<TenantListResponse> {
|
||||
const response = await axios.get<TenantListResponse>(
|
||||
`${API_BASE_URL}/api/v1/tenants`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant information by ID
|
||||
*/
|
||||
async getTenant(tenantId: number): Promise<Tenant> {
|
||||
const response = await axios.get<Tenant>(
|
||||
`${API_BASE_URL}/api/v1/tenants/${tenantId}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new tenant
|
||||
*/
|
||||
async createTenant(data: TenantCreateRequest): Promise<TenantCreateResponse> {
|
||||
const response = await axios.post<TenantCreateResponse>(
|
||||
`${API_BASE_URL}/api/v1/tenants`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tenant information
|
||||
*/
|
||||
async updateTenant(
|
||||
tenantId: number,
|
||||
data: TenantUpdateRequest
|
||||
): Promise<TenantUpdateResponse> {
|
||||
const response = await axios.put<TenantUpdateResponse>(
|
||||
`${API_BASE_URL}/api/v1/tenants/${tenantId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantService = new TenantService()
|
||||
Reference in New Issue
Block a user