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
Expand all
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
This diff is collapsed.
Click to expand it.
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