feat(admin): CRUD jobs

parent c7f7ef38
......@@ -11,7 +11,12 @@ const ADMIN_ROUTES: Route[] = [
{
path: '',
pathMatch: 'full',
redirectTo: 'configuration',
redirectTo: 'dashboard',
},
{
path: 'dashboard',
loadComponent: () =>
import('./pages/dashboard/dashboard.component').then(m => m.DashboardComponent),
},
],
},
......
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
import { NzInputNumberModule } from 'ng-zorro-antd/input-number';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzSpaceModule } from 'ng-zorro-antd/space';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { Job, JobFormData, JobType, JobLocation } from '../../../shared/data-access/interface/job.interface';
@Component({
selector: 'job-form',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
FormsModule,
NzFormModule,
NzInputModule,
NzSelectModule,
NzButtonModule,
NzDatePickerModule,
NzInputNumberModule,
NzTagModule,
NzIconModule,
NzSpaceModule,
NzDividerModule
],
template: `
<form nz-form [formGroup]="jobForm" (ngSubmit)="onSubmit()" class="tw-space-y-6">
<!-- Basic Information -->
<div class="tw-bg-white tw-p-6 tw-rounded-lg tw-border tw-border-gray-200">
<h3 class="tw-text-lg tw-font-semibold tw-text-gray-900 tw-mb-4">Basic Information</h3>
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 tw-gap-4">
<nz-form-item>
<nz-form-label [nzRequired]="true">Job Title</nz-form-label>
<nz-form-control [nzErrorTip]="'Please enter job title'">
<input
nz-input
formControlName="title"
placeholder="e.g. Senior Java Developer"
class="tw-w-full" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzRequired]="true">Job Type</nz-form-label>
<nz-form-control [nzErrorTip]="'Please select job type'">
<nz-select formControlName="type" nzPlaceHolder="Select job type" class="tw-w-full">
<nz-option nzValue="Full Time" nzLabel="Full Time"></nz-option>
<nz-option nzValue="Part Time" nzLabel="Part Time"></nz-option>
<nz-option nzValue="Remote" nzLabel="Remote"></nz-option>
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzRequired]="true">Company Name</nz-form-label>
<nz-form-control [nzErrorTip]="'Please enter company name'">
<input
nz-input
formControlName="company"
placeholder="e.g. Rabobank"
class="tw-w-full" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label>Company Website</nz-form-label>
<nz-form-control [nzErrorTip]="'Please enter valid URL'">
<input
nz-input
formControlName="company_url"
placeholder="https://www.company.com"
class="tw-w-full" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzRequired]="true">Location</nz-form-label>
<nz-form-control [nzErrorTip]="'Please select location'">
<nz-select formControlName="location" nzPlaceHolder="Select location" class="tw-w-full">
<nz-option nzValue="Viet Nam" nzLabel="Viet Nam"></nz-option>
<nz-option nzValue="Lao" nzLabel="Lao"></nz-option>
<nz-option nzValue="Campuchia" nzLabel="Campuchia"></nz-option>
</nz-select>
</nz-form-control>
</nz-form-item>
</div>
</div>
<!-- Job Description -->
<div class="tw-bg-white tw-p-6 tw-rounded-lg tw-border tw-border-gray-200">
<h3 class="tw-text-lg tw-font-semibold tw-text-gray-900 tw-mb-4">Job Description</h3>
<nz-form-item>
<nz-form-label [nzRequired]="true">Description</nz-form-label>
<nz-form-control [nzErrorTip]="'Please enter job description'">
<textarea
nz-input
formControlName="description"
nzRows="6"
placeholder="Describe the job role, responsibilities, and what you're looking for..."
class="tw-w-full">
</textarea>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label>How to Apply</nz-form-label>
<nz-form-control>
<textarea
nz-input
formControlName="how_to_apply"
nzRows="3"
placeholder="Instructions on how to apply for this job (optional)..."
class="tw-w-full">
</textarea>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label>Company Logo URL</nz-form-label>
<nz-form-control>
<input
nz-input
formControlName="company_logo"
placeholder="https://example.com/logo.png (optional)"
class="tw-w-full" />
</nz-form-control>
</nz-form-item>
</div>
<!-- Form Actions -->
<div class="tw-flex tw-justify-end tw-space-x-4 tw-pt-6">
<button
nz-button
nzType="default"
type="button"
(click)="onCancel()">
Cancel
</button>
<button
nz-button
nzType="primary"
type="submit"
[nzLoading]="loading()"
[disabled]="!jobForm.valid">
{{ isEditMode ? 'Update Job' : 'Create Job' }}
</button>
</div>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class JobFormComponent implements OnInit {
private readonly fb = inject(FormBuilder);
@Input() job: Job | null = null;
@Input() loading = signal(false);
@Output() formSubmit = new EventEmitter<JobFormData>();
@Output() formCancel = new EventEmitter<void>();
jobForm!: FormGroup;
isEditMode = false;
ngOnInit(): void {
this.isEditMode = !!this.job;
this.initializeForm();
if (this.job) {
this.populateForm(this.job);
}
}
private initializeForm(): void {
this.jobForm = this.fb.group({
title: ['', [Validators.required, Validators.minLength(3)]],
description: ['', [Validators.required, Validators.minLength(10)]],
company: ['', [Validators.required]],
company_url: ['', [Validators.pattern(/^https?:\/\/.+/)]],
location: ['', [Validators.required]],
type: ['', [Validators.required]],
how_to_apply: [''],
company_logo: ['']
});
}
private populateForm(job: Job): void {
this.jobForm.patchValue({
title: job.title,
description: job.description,
company: job.company,
company_url: job.company_url,
location: job.location,
type: job.type,
how_to_apply: job.how_to_apply || '',
company_logo: job.company_logo || ''
});
}
onSubmit(): void {
if (this.jobForm.valid) {
const formValue = this.jobForm.value;
const jobData: JobFormData = {
...formValue
};
this.formSubmit.emit(jobData);
}
}
onCancel(): void {
this.formCancel.emit();
}
}
import { RouterLink, RouterModule, RouterOutlet } from '@angular/router';
import { RouterModule, RouterOutlet } from '@angular/router';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { CommonModule } from '@angular/common';
import { HeaderComponent } from '../../../../+shell/ui/components/header/feature/header.component';
@Component({
selector: 'admin-layout',
......@@ -10,15 +10,21 @@ import { CommonModule } from '@angular/common';
imports: [
RouterOutlet,
NzLayoutModule,
RouterLink,
RouterModule,
CommonModule,
HeaderComponent,
],
template: ` <nz-content>
<div>
<router-outlet></router-outlet>
</div>
</nz-content>`,
template: `
<nz-layout class="tw-min-h-screen">
<meu-header></meu-header>
<nz-content class="tw-flex-1">
<div class="tw-container tw-mx-auto tw-px-4 tw-py-6">
<router-outlet></router-outlet>
</div>
</nz-content>
</nz-layout>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminLayoutComponent implements OnInit {
......
This diff is collapsed.
......@@ -18,7 +18,7 @@
<div class="tw-flex tw-items-center tw-space-x-6">
<!-- User Info -->
<nz-card
class="tw-bg-gray-50 tw-rounded-2xl tw-shadow-none"
class="tw-rounded-2xl tw-shadow-none"
[nzBordered]="false"
[nzBodyStyle]="{ padding: '12px 16px' }">
<div class="tw-flex tw-items-center tw-space-x-3">
......@@ -31,7 +31,7 @@
<div class="tw-font-semibold tw-text-gray-900">{{ authState.user?.username || 'User' }}</div>
<nz-tag
[nzColor]="authState.user?.role === 'admin' ? 'blue' : 'green'"
class="tw-text-xs tw-font-medium tw-capitalize tw-mt-1">
class="tw-text-xs tw-font-medium tw-capitalize tw-mt-0">
{{ authState.user?.role }}
</nz-tag>
</div>
......
export interface Pagination {
page: number;
limit: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface ApiResponse<T = any> {
message: string;
success: boolean;
status: number;
data?: T;
}
export interface ListResponse<T = any> extends ApiResponse {
responseData: {
items: T[];
total: number;
page: number;
limit: number;
};
}
import { Pagination } from './common.interface';
export interface Job {
id: string;
title: string;
description: string;
company: string;
company_url: string;
location: string;
type: JobType;
created_at: string;
how_to_apply?: string;
company_logo?: string | null;
}
export type JobLocation = 'Viet Nam' | 'Lao' | 'Campuchia';
export type JobType = 'Full Time' | 'Part Time' | 'Remote';
export interface JobFormData {
title: string;
description: string;
company: string;
company_url: string;
location: JobLocation;
type: JobType;
how_to_apply?: string;
company_logo?: string | null;
}
export interface CreateJobRequest {
job: JobFormData;
}
export interface UpdateJobRequest {
id: string;
job: Partial<JobFormData>;
}
export interface JobResponse {
message: string;
responseData: {
job: Job;
};
success: boolean;
status: number;
}
export interface JobListResponse {
message: string;
responseData: {
jobs: Job[];
total: number;
page: number;
limit: number;
};
success: boolean;
status: number;
}
export interface JobStats {
totalJobs: number;
companiesCount: number;
}
export interface JobFilter {
type?: JobType;
company?: string;
location?: JobLocation;
search?: string;
}
export interface JobPagination extends Pagination {}
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, throwError, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import {
Job,
JobListResponse,
JobResponse,
CreateJobRequest,
UpdateJobRequest,
JobStats,
JobFilter,
JobPagination,
JobLocation,
JobType
} from '../interface/job.interface';
import { environment } from '../../../../environments/environment';
import { StorageService } from './storage.service';
// API response format that matches your data structure
interface ApiJobResponse {
message: string;
responseData: {
items: ApiJob[];
};
}
interface ApiJob {
id: string;
type: string;
created_at: string;
company: string;
company_url: string;
location: string;
title: string;
description: string;
}
@Injectable({ providedIn: 'root' })
export class JobApiService {
private readonly http = inject(HttpClient);
private readonly storageService = inject(StorageService);
private readonly API_URL = `${environment.API_DOMAIN}/jobs`;
// HTTP Headers
private getHeaders = (): HttpHeaders => {
const headers: { [key: string]: string } = { 'Content-Type': 'application/json' };
const token = this.storageService.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return new HttpHeaders(headers);
};
private buildParams = (filter?: JobFilter, pagination?: JobPagination): HttpParams => {
let params = new HttpParams();
if (pagination) {
params = params.set('page', pagination.page.toString());
params = params.set('limit', pagination.limit.toString());
if (pagination.sortBy) params = params.set('sortBy', pagination.sortBy);
if (pagination.sortOrder) params = params.set('sortOrder', pagination.sortOrder);
}
if (filter) {
if (filter.type) params = params.set('type', filter.type);
if (filter.company) params = params.set('company', filter.company);
if (filter.location) params = params.set('location', filter.location);
if (filter.search) params = params.set('search', filter.search);
}
return params;
};
private convertApiJobToJob = (apiJob: ApiJob): Job => {
return {
id: apiJob.id,
title: apiJob.title,
description: apiJob.description,
company: apiJob.company,
company_url: apiJob.company_url,
location: apiJob.location,
type: this.mapApiTypeToJobType(apiJob.type),
created_at: apiJob.created_at,
how_to_apply: "",
company_logo: null
};
};
private mapApiTypeToJobType = (apiType: string): JobType => {
if (apiType.toLowerCase().includes('part')) return 'Part Time';
if (apiType.toLowerCase().includes('remote')) return 'Remote';
return 'Full Time';
};
// API Calls
getJobs = (filter?: JobFilter, pagination?: JobPagination): Observable<JobListResponse> => {
const params = this.buildParams(filter, pagination);
return this.http.get<ApiJobResponse>(this.API_URL, {
headers: this.getHeaders(),
params
}).pipe(
map(response => {
const jobs = response.responseData.items.map(this.convertApiJobToJob);
return {
message: response.message,
responseData: {
jobs: jobs,
total: jobs.length,
page: pagination?.page || 1,
limit: pagination?.limit || 10
},
success: true,
status: 200
};
}),
catchError(this.handleError)
);
};
createJob = (request: CreateJobRequest): Observable<JobResponse> => {
const apiJobData = {
title: request.job.title,
description: request.job.description,
company: request.job.company,
company_url: request.job.company_url,
location: request.job.location,
type: request.job.type
};
return this.http.post<ApiJob>(this.API_URL, apiJobData, {
headers: this.getHeaders()
}).pipe(
map(apiJob => ({
message: 'Job created successfully',
responseData: {
job: this.convertApiJobToJob(apiJob)
},
success: true,
status: 201
})),
catchError(this.handleError)
);
};
updateJob = (request: UpdateJobRequest): Observable<JobResponse> => {
// Convert internal job format to API format
const apiJobData = {
title: request.job.title,
description: request.job.description,
company: request.job.company,
company_url: request.job.company_url,
location: request.job.location,
type: request.job.type
};
return this.http.put<ApiJob>(`${this.API_URL}/${request.id}`, apiJobData, {
headers: this.getHeaders()
}).pipe(
map(apiJob => ({
message: 'Job updated successfully',
responseData: {
job: this.convertApiJobToJob(apiJob)
},
success: true,
status: 200
})),
catchError(this.handleError)
);
};
deleteJob = (id: string): Observable<{ success: boolean; message: string }> =>
this.http.delete(`${this.API_URL}/${id}`, {
headers: this.getHeaders()
}).pipe(
map(() => ({
success: true,
message: 'Job deleted successfully'
})),
catchError(this.handleError)
);
getJobStats = (): Observable<{ success: boolean; data: JobStats }> => {
// Since the API doesn't provide stats endpoint, we'll calculate from jobs
return this.getJobs().pipe(
map(response => {
const jobs = response.responseData.jobs;
const stats: JobStats = {
totalJobs: jobs.length,
companiesCount: new Set(jobs.map(job => job.company)).size
};
return {
success: true,
data: stats
};
}),
catchError(error => {
console.error('Failed to get job stats:', error);
return of({
success: false,
data: {
totalJobs: 0,
companiesCount: 0
}
});
})
);
};
// Error handling
private handleError = (error: any): Observable<never> => {
let message = 'An error occurred';
if (error.error?.message) {
message = error.error.message;
} else if (error.message) {
message = error.message;
}
const statusMessage = this.getStatusMessage(error.status, message);
return throwError(() => ({ status: error.status, message: statusMessage }));
};
private getStatusMessage = (status: number, defaultMessage: string): string => {
const statusMessages: Record<number, string> = {
400: 'Invalid request data',
401: 'Authentication required',
403: 'Access denied',
404: 'Job not found',
409: 'Job already exists',
422: 'Validation failed',
500: 'Server error. Please try again later.',
503: 'Service unavailable'
};
return statusMessages[status] || defaultMessage;
};
}
import { Injectable, inject, signal, computed } from '@angular/core';
import { Observable, catchError, of, tap, map } from 'rxjs';
import { Job, JobStats, JobListResponse } from '../interface/job.interface';
import { JobApiService } from './job-api.service';
@Injectable({ providedIn: 'root' })
export class JobStateService {
private readonly jobApiService = inject(JobApiService);
private jobsSignal = signal<Job[]>([]);
private loadingSignal = signal<boolean>(false);
private errorSignal = signal<string | null>(null);
private statsSignal = signal<JobStats | null>(null);
// Public readonly signals
jobs = this.jobsSignal.asReadonly();
loading = this.loadingSignal.asReadonly();
error = this.errorSignal.asReadonly();
stats = this.statsSignal.asReadonly();
// Computed properties
totalJobs = computed(() => this.stats()?.totalJobs || this.jobs().length);
companiesCount = computed(() => this.stats()?.companiesCount || new Set(this.jobs().map(job => job.company)).size);
// Actions
loadJobs = (): Observable<Job[]> => {
this.setLoading(true);
this.clearError();
return this.jobApiService.getJobs().pipe(
tap(response => {
if (response.success) {
this.setJobs(response.responseData.jobs);
} else {
this.setError(response.message || 'Failed to load jobs');
}
}),
map((response: JobListResponse) => {
return response.success ? response.responseData.jobs : [];
}),
catchError((error: any) => {
this.setError(error.message || 'Failed to load jobs');
return of([]);
}),
tap(() => this.setLoading(false))
);
};
loadJobStats = (): Observable<JobStats | null> => {
return this.jobApiService.getJobStats().pipe(
tap(response => {
if (response && response.success) {
this.statsSignal.set(response.data);
}
}),
map((response: any) => {
return response && response.success ? response.data : null;
}),
catchError(error => {
console.error('Failed to load job stats:', error);
return of(null);
})
);
};
addJob = (job: Job): void => {
const currentJobs = this.jobsSignal();
this.setJobs([job, ...currentJobs]);
this.updateStats();
};
updateJob = (updatedJob: Job): void => {
const currentJobs = this.jobsSignal();
const updatedJobs = currentJobs.map(job =>
job.id === updatedJob.id ? updatedJob : job
);
this.setJobs(updatedJobs);
this.updateStats();
};
removeJob = (jobId: string): void => {
const currentJobs = this.jobsSignal();
this.setJobs(currentJobs.filter(job => job.id !== jobId));
this.updateStats();
};
private setJobs = (jobs: Job[]): void => {
this.jobsSignal.set(jobs);
};
private setLoading = (loading: boolean): void => {
this.loadingSignal.set(loading);
};
private setError = (error: string | null): void => {
this.errorSignal.set(error);
};
private clearError = (): void => {
this.errorSignal.set(null);
};
private updateStats = (): void => {
const jobs = this.jobsSignal();
const stats: JobStats = {
totalJobs: jobs.length,
companiesCount: new Set(jobs.map(job => job.company)).size
};
this.statsSignal.set(stats);
};
}
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
Job,
JobFormData,
JobStats
} from '../interface/job.interface';
import { JobApiService } from './job-api.service';
import { JobStateService } from './job-state.service';
@Injectable({ providedIn: 'root' })
export class JobService {
private readonly jobApiService = inject(JobApiService);
private readonly jobStateService = inject(JobStateService);
// Expose state
jobs = this.jobStateService.jobs;
loading = this.jobStateService.loading;
error = this.jobStateService.error;
stats = this.jobStateService.stats;
// Computed stats
totalJobs = this.jobStateService.totalJobs;
companiesCount = this.jobStateService.companiesCount;
constructor() {
// Load jobs from API on service initialization
this.loadJobs().subscribe({
next: () => {
console.log('Initial jobs loaded from API');
},
error: (error) => {
console.error('Failed to load initial jobs from API:', error);
}
});
}
// Main API methods
loadJobs = (): Observable<Job[]> =>
this.jobStateService.loadJobs();
loadJobStats = (): Observable<JobStats | null> =>
this.jobStateService.loadJobStats();
// Helper methods for job data formatting
formatJobData = (formData: JobFormData): JobFormData => {
return {
...formData,
title: formData.title.trim(),
description: formData.description.trim(),
company: formData.company.trim(),
company_url: formData.company_url.trim()
};
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment