Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
M
Meu-Template-Angular-CSR
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Trần Anh Phú
Meu-Template-Angular-CSR
Commits
0db63e9d
Commit
0db63e9d
authored
Jul 09, 2025
by
Nguyễn Thị Thanh Trúc
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(admin): CRUD jobs
parent
c7f7ef38
Changes
10
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
1190 additions
and
11 deletions
+1190
-11
admin.routes.ts
src/app/+admin/admin.routes.ts
+6
-1
job-form.component.ts
src/app/+admin/components/job-form/job-form.component.ts
+222
-0
layout.component.ts
src/app/+admin/layout/feature/ui/layout.component.ts
+14
-8
dashboard.component.ts
src/app/+admin/pages/dashboard/dashboard.component.ts
+444
-0
header.component.html
...+shell/ui/components/header/feature/header.component.html
+2
-2
common.interface.ts
src/app/shared/data-access/interface/common.interface.ts
+22
-0
job.interface.ts
src/app/shared/data-access/interface/job.interface.ts
+73
-0
job-api.service.ts
src/app/shared/data-access/service/job-api.service.ts
+241
-0
job-state.service.ts
src/app/shared/data-access/service/job-state.service.ts
+111
-0
job.service.ts
src/app/shared/data-access/service/job.service.ts
+55
-0
No files found.
src/app/+admin/admin.routes.ts
View file @
0db63e9d
...
...
@@ -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
),
},
],
},
...
...
src/app/+admin/components/job-form/job-form.component.ts
0 → 100644
View file @
0db63e9d
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
();
}
}
src/app/+admin/layout/feature/ui/layout.component.ts
View file @
0db63e9d
import
{
Router
Link
,
Router
Module
,
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
{
...
...
src/app/+admin/pages/dashboard/dashboard.component.ts
0 → 100644
View file @
0db63e9d
import
{
Component
,
ChangeDetectionStrategy
,
inject
,
OnInit
,
signal
}
from
'@angular/core'
;
import
{
CommonModule
}
from
'@angular/common'
;
import
{
NzTableModule
}
from
'ng-zorro-antd/table'
;
import
{
NzButtonModule
}
from
'ng-zorro-antd/button'
;
import
{
NzIconModule
}
from
'ng-zorro-antd/icon'
;
import
{
NzTagModule
}
from
'ng-zorro-antd/tag'
;
import
{
NzCardModule
}
from
'ng-zorro-antd/card'
;
import
{
NzDropDownModule
}
from
'ng-zorro-antd/dropdown'
;
import
{
NzMenuModule
}
from
'ng-zorro-antd/menu'
;
import
{
NzModalModule
}
from
'ng-zorro-antd/modal'
;
import
{
NzMessageModule
}
from
'ng-zorro-antd/message'
;
import
{
NzMessageService
}
from
'ng-zorro-antd/message'
;
import
{
NzSpinModule
}
from
'ng-zorro-antd/spin'
;
import
{
NzDescriptionsModule
}
from
'ng-zorro-antd/descriptions'
;
import
{
Job
,
JobFormData
}
from
'../../../shared/data-access/interface/job.interface'
;
import
{
JobStateService
}
from
'../../../shared/data-access/service/job-state.service'
;
import
{
JobApiService
}
from
'../../../shared/data-access/service/job-api.service'
;
import
{
JobFormComponent
}
from
'../../components/job-form/job-form.component'
;
@
Component
({
selector
:
'admin-dashboard'
,
standalone
:
true
,
imports
:
[
CommonModule
,
NzTableModule
,
NzButtonModule
,
NzIconModule
,
NzTagModule
,
NzCardModule
,
NzDropDownModule
,
NzMenuModule
,
NzModalModule
,
NzMessageModule
,
NzSpinModule
,
NzDescriptionsModule
,
JobFormComponent
],
template
:
`
<div class="tw-p-6">
<!-- Header Section -->
<div class="tw-flex tw-justify-between tw-items-center tw-mb-6">
<div>
<h1 class="tw-text-3xl tw-font-bold tw-text-gray-900 tw-mb-2">Job Management Dashboard</h1>
</div>
<button
nz-button
nzType="primary"
nzSize="large"
(click)="openAddJobModal()"
class="tw-bg-blue-600 hover:tw-bg-blue-700">
<span nz-icon nzType="plus" class="tw-mr-1 tw-relative tw--top-[2px]"></span>
Add
</button>
</div>
<!-- Jobs Table -->
<nz-card [nzBordered]="false" class="tw-shadow-lg">
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
<h2 class="tw-text-xl tw-font-semibold tw-text-gray-900">Recent Job Postings</h2>
<button nz-button nzType="default" (click)="refreshJobs()" [nzLoading]="jobStateService.loading()">
<span nz-icon nzType="reload" class="tw-relative tw--top-[3px]"></span>
Refresh
</button>
</div>
<nz-spin [nzSpinning]="jobStateService.loading()">
<nz-table
#jobsTable
[nzData]="jobStateService.jobs()"
[nzPageSize]="10"
[nzShowSizeChanger]="true"
[nzShowQuickJumper]="true"
[nzShowTotal]="totalTemplate"
class="tw-w-full">
<thead>
<tr>
<th nzWidth="80px">STT</th>
<th nzWidth="300px">Name</th>
<th nzWidth="120px">Type</th>
<th nzWidth="150px">Location</th>
<th nzWidth="120px" nzAlign="center">Action</th>
</tr>
</thead>
<tbody>
@for (job of jobsTable.data; track job.id; let i = $index) {
<tr>
<td>
<span class="tw-font-medium tw-text-gray-900">{{ i + 1 }}</span>
</td>
<td>
<div class="tw-font-medium tw-text-gray-900">{{ job.title }}</div>
<div class="tw-text-sm tw-text-gray-500">{{ job.company }}</div>
</td>
<td>
<nz-tag [nzColor]="getJobTypeColor(job.type)">
{{ job.type }}
</nz-tag>
</td>
<td>
<span class="tw-text-gray-700">{{ job.location }}</span>
</td>
<td nzAlign="center">
<button
nz-dropdown
[nzDropdownMenu]="menu"
nzPlacement="bottomRight"
nz-button
nzType="text"
nzSize="small">
<span nz-icon nzType="more" nzTheme="outline"></span>
</button>
<nz-dropdown-menu #menu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item (click)="viewJobDetail(job)">
<span nz-icon nzType="eye" class="tw-mr-2"></span>
View Details
</li>
<li nz-menu-item (click)="editJob(job)">
<span nz-icon nzType="edit" class="tw-mr-2"></span>
Edit Job
</li>
<li nz-menu-divider></li>
<li nz-menu-item (click)="confirmDeleteJob(job)" class="tw-text-red-600">
<span nz-icon nzType="delete" class="tw-mr-2"></span>
Delete Job
</li>
</ul>
</nz-dropdown-menu>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-spin>
<ng-template #totalTemplate let-total>
Total {{ total }} jobs
</ng-template>
</nz-card>
</div>
<!-- Add/Edit Job Modal -->
@if (showJobFormModal) {
<nz-modal
[(nzVisible)]="showJobFormModal"
[nzTitle]="isEditMode ? 'Edit Job' : 'Add New Job'"
[nzWidth]="900"
[nzFooter]="null"
(nzOnCancel)="closeJobFormModal()">
<div *nzModalContent>
<job-form
[job]="selectedJob"
[loading]="jobFormLoading"
(formSubmit)="onJobFormSubmit($event)"
(formCancel)="closeJobFormModal()">
</job-form>
</div>
</nz-modal>
}
<!-- Job Detail Modal -->
@if (showDetailModal && selectedJob) {
<nz-modal
[(nzVisible)]="showDetailModal"
[nzTitle]="'Job Details'"
[nzWidth]="800"
[nzFooter]="null"
(nzOnCancel)="closeDetailModal()">
<div *nzModalContent>
<!-- Header -->
<div class="tw-mb-6">
<h2 class="tw-text-2xl tw-font-bold tw-text-gray-900 tw-mb-2">{{ selectedJob.title }}</h2>
<div class="tw-flex tw-flex-wrap tw-items-center tw-gap-4 tw-text-sm tw-text-gray-600">
<span class="tw-flex tw-items-center tw-gap-1">
<span nz-icon nzType="bank" class="tw-text-blue-500"></span>
<a [href]="selectedJob.company_url" target="_blank" class="tw-text-blue-600 hover:tw-text-blue-800">
{{ selectedJob.company }}
</a>
</span>
<span class="tw-flex tw-items-center tw-gap-1">
<span nz-icon nzType="environment" class="tw-text-green-500"></span>
{{ selectedJob.location }}
</span>
<span class="tw-flex tw-items-center tw-gap-1">
<span nz-icon nzType="calendar" class="tw-text-purple-500"></span>
{{ formatDate(selectedJob.created_at) }}
</span>
</div>
</div>
<!-- Type Tag -->
<div class="tw-mb-6">
<nz-tag [nzColor]="getJobTypeColor(selectedJob.type)">
{{ selectedJob.type }}
</nz-tag>
</div>
<!-- Job Information -->
<nz-descriptions nzBordered nzSize="middle" class="tw-mb-6">
<nz-descriptions-item nzTitle="Job ID">{{ selectedJob.id }}</nz-descriptions-item>
<nz-descriptions-item nzTitle="Type">
<nz-tag [nzColor]="getJobTypeColor(selectedJob.type)">{{ selectedJob.type }}</nz-tag>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Company">
@if (selectedJob.company_logo) {
<div class="tw-flex tw-items-center tw-gap-2">
<img [src]="selectedJob.company_logo" [alt]="selectedJob.company" class="tw-w-6 tw-h-6 tw-rounded">
<a [href]="selectedJob.company_url" target="_blank" class="tw-text-blue-600 hover:tw-text-blue-800">
{{ selectedJob.company }}
</a>
</div>
} @else {
<a [href]="selectedJob.company_url" target="_blank" class="tw-text-blue-600 hover:tw-text-blue-800">
{{ selectedJob.company }}
</a>
}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="Location">{{ selectedJob.location }}</nz-descriptions-item>
<nz-descriptions-item nzTitle="Created">{{ formatDate(selectedJob.created_at) }}</nz-descriptions-item>
</nz-descriptions>
<!-- Description -->
<div class="tw-mb-6">
<h3 class="tw-text-lg tw-font-semibold tw-text-gray-900 tw-mb-3">Job Description</h3>
<div class="tw-prose tw-prose-sm tw-max-w-none" [innerHTML]="selectedJob.description"></div>
</div>
<!-- How to Apply -->
@if (selectedJob.how_to_apply) {
<div class="tw-mb-6">
<h3 class="tw-text-lg tw-font-semibold tw-text-gray-900 tw-mb-3">How to Apply</h3>
<div class="tw-prose tw-prose-sm tw-max-w-none" [innerHTML]="selectedJob.how_to_apply"></div>
</div>
}
<!-- Actions -->
<div class="tw-flex tw-justify-end tw-space-x-4 tw-pt-4 tw-border-t tw-border-gray-200">
<button
nz-button
nzType="default"
(click)="closeDetailModal()">
Close
</button>
<button
nz-button
nzType="primary"
(click)="onEditJobFromDetail(selectedJob)">
<span nz-icon nzType="edit"></span>
Edit Job
</button>
</div>
</div>
</nz-modal>
}
`
,
changeDetection
:
ChangeDetectionStrategy
.
Default
,
})
export
class
DashboardComponent
implements
OnInit
{
readonly
jobStateService
=
inject
(
JobStateService
);
private
readonly
jobApiService
=
inject
(
JobApiService
);
private
readonly
messageService
=
inject
(
NzMessageService
);
showJobFormModal
:
boolean
=
false
;
showDetailModal
:
boolean
=
false
;
jobFormLoading
=
signal
<
boolean
>
(
false
);
isEditMode
:
boolean
=
false
;
selectedJob
:
Job
|
null
=
null
;
ngOnInit
():
void
{
this
.
loadInitialData
();
}
private
loadInitialData
():
void
{
this
.
jobStateService
.
loadJobs
().
subscribe
({
next
:
()
=>
{
console
.
log
(
'Jobs loaded successfully from API'
);
},
error
:
(
error
)
=>
{
console
.
error
(
'Failed to load jobs from API:'
,
error
);
this
.
messageService
.
error
(
`Failed to load jobs:
${
error
.
message
}
`
);
}
});
this
.
jobStateService
.
loadJobStats
().
subscribe
({
next
:
()
=>
{
console
.
log
(
'Job stats loaded successfully from API'
);
},
error
:
(
error
)
=>
{
console
.
warn
(
'Failed to load job stats from API:'
,
error
);
}
});
}
// Job actions
openAddJobModal
():
void
{
this
.
isEditMode
=
false
;
this
.
selectedJob
=
null
;
this
.
showJobFormModal
=
true
;
}
editJob
(
job
:
Job
):
void
{
this
.
isEditMode
=
true
;
this
.
selectedJob
=
job
;
this
.
showJobFormModal
=
true
;
}
viewJobDetail
(
job
:
Job
):
void
{
this
.
selectedJob
=
job
;
this
.
showDetailModal
=
true
;
console
.
log
(
'Opening job detail for:'
,
job
.
title
);
}
confirmDeleteJob
(
job
:
Job
):
void
{
const
modalRef
=
this
.
messageService
.
loading
(
'Preparing to delete job...'
,
{
nzDuration
:
0
});
setTimeout
(()
=>
{
this
.
messageService
.
remove
(
modalRef
.
messageId
);
if
(
confirm
(
`Are you sure you want to delete the job "
${
job
.
title
}
"? This action cannot be undone.`
))
{
this
.
deleteJob
(
job
);
}
},
500
);
}
deleteJob
(
job
:
Job
):
void
{
this
.
jobApiService
.
deleteJob
(
job
.
id
).
subscribe
({
next
:
()
=>
{
this
.
jobStateService
.
removeJob
(
job
.
id
);
this
.
messageService
.
success
(
`Job "
${
job
.
title
}
" deleted successfully`
);
},
error
:
(
error
)
=>
{
this
.
messageService
.
error
(
`Failed to delete job:
${
error
.
message
}
`
);
}
});
}
refreshJobs
():
void
{
this
.
jobStateService
.
loadJobs
().
subscribe
({
next
:
()
=>
{
this
.
messageService
.
success
(
'Jobs refreshed successfully'
);
},
error
:
(
error
)
=>
{
this
.
messageService
.
error
(
`Failed to refresh jobs:
${
error
.
message
}
`
);
}
});
}
// Modal handlers
closeJobFormModal
():
void
{
this
.
showJobFormModal
=
false
;
this
.
selectedJob
=
null
;
this
.
isEditMode
=
false
;
this
.
jobFormLoading
.
set
(
false
);
}
closeDetailModal
():
void
{
console
.
log
(
'Closing detail modal...'
);
this
.
showDetailModal
=
false
;
this
.
selectedJob
=
null
;
console
.
log
(
'Detail modal closed, state reset'
);
}
onEditJobFromDetail
(
job
:
Job
):
void
{
// Close detail modal and open edit modal
this
.
showDetailModal
=
false
;
this
.
isEditMode
=
true
;
this
.
selectedJob
=
job
;
this
.
showJobFormModal
=
true
;
}
onJobFormSubmit
(
jobData
:
JobFormData
):
void
{
this
.
jobFormLoading
.
set
(
true
);
if
(
this
.
isEditMode
&&
this
.
selectedJob
)
{
// Update job
this
.
jobApiService
.
updateJob
({
id
:
this
.
selectedJob
.
id
,
job
:
jobData
}).
subscribe
({
next
:
(
response
)
=>
{
if
(
response
.
success
)
{
this
.
jobStateService
.
updateJob
(
response
.
responseData
.
job
);
this
.
messageService
.
success
(
`Job "
${
response
.
responseData
.
job
.
title
}
" updated successfully`
);
this
.
closeJobFormModal
();
}
else
{
this
.
messageService
.
error
(
`Failed to update job:
${
response
.
message
}
`
);
this
.
jobFormLoading
.
set
(
false
);
}
},
error
:
(
error
)
=>
{
this
.
messageService
.
error
(
`Failed to update job:
${
error
.
message
}
`
);
this
.
jobFormLoading
.
set
(
false
);
}
});
}
else
{
// Create new job
this
.
jobApiService
.
createJob
({
job
:
jobData
}).
subscribe
({
next
:
(
response
)
=>
{
if
(
response
.
success
)
{
this
.
jobStateService
.
addJob
(
response
.
responseData
.
job
);
this
.
messageService
.
success
(
`Job "
${
response
.
responseData
.
job
.
title
}
" created successfully`
);
this
.
closeJobFormModal
();
}
else
{
this
.
messageService
.
error
(
`Failed to create job:
${
response
.
message
}
`
);
this
.
jobFormLoading
.
set
(
false
);
}
},
error
:
(
error
)
=>
{
this
.
messageService
.
error
(
`Failed to create job:
${
error
.
message
}
`
);
this
.
jobFormLoading
.
set
(
false
);
}
});
}
}
getJobDescription
(
description
:
string
):
string
{
const
stripped
=
description
.
replace
(
/<
[^
>
]
*>/g
,
''
);
return
stripped
.
length
>
100
?
stripped
.
substring
(
0
,
100
)
+
'...'
:
stripped
;
}
getJobTypeColor
(
type
:
string
):
string
{
switch
(
type
)
{
case
'Full Time'
:
return
'blue'
;
case
'Part Time'
:
return
'cyan'
;
case
'Remote'
:
return
'purple'
;
default
:
return
'default'
;
}
}
formatDate
(
dateString
:
string
):
string
{
return
new
Date
(
dateString
).
toLocaleDateString
(
'en-US'
,
{
year
:
'numeric'
,
month
:
'short'
,
day
:
'numeric'
});
}
}
src/app/+shell/ui/components/header/feature/header.component.html
View file @
0db63e9d
...
...
@@ -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>
...
...
src/app/shared/data-access/interface/common.interface.ts
0 → 100644
View file @
0db63e9d
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
;
};
}
src/app/shared/data-access/interface/job.interface.ts
0 → 100644
View file @
0db63e9d
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
{}
src/app/shared/data-access/service/job-api.service.ts
0 → 100644
View file @
0db63e9d
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
;
};
}
src/app/shared/data-access/service/job-state.service.ts
0 → 100644
View file @
0db63e9d
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
);
};
}
src/app/shared/data-access/service/job.service.ts
0 → 100644
View file @
0db63e9d
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
()
};
};
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment