535 lines
21 KiB
JavaScript
535 lines
21 KiB
JavaScript
/* ============================================================
|
||
JobProfile — full-page job profile at /job/:jobId (replaces the Jobs
|
||
board's detail modal, per the "Job Profile" design).
|
||
|
||
One request, GET /jobs/profile/fetch, feeds the whole page: the requisition
|
||
row, the suggested candidates and the Suggested / Top Match header stats.
|
||
Suggested = the newest ats_results score per person for this job; Top Match
|
||
= how many of them sit in the Strong Match band. Search and the "Show" size
|
||
go to the API as `search` / `top`; band filter and sort then run client-side
|
||
over the rows that come back.
|
||
|
||
Tabs: Details · Suggested Candidates · History (?tab= deep-links a tab).
|
||
============================================================ */
|
||
|
||
import { useMemo, useState } from 'react'
|
||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
|
||
import { Tabs } from '../ui/Tabs'
|
||
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
|
||
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
|
||
import { useToast } from '../ui/Toast'
|
||
import { useAuth } from '../auth/AuthContext'
|
||
import { qk } from '../lib/queryKeys'
|
||
import { friendlyAuthError } from '../lib/errors'
|
||
import { platformLabel } from '../lib/platforms'
|
||
import { fmtShort } from '../lib/format'
|
||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||
import * as jobsApi from '../api/jobs'
|
||
import * as assignmentsApi from '../api/assignments'
|
||
import * as offersApi from '../api/offers'
|
||
import { EditJobForm, JobCover, JobHistory, JobOwnership, SECTION_LABEL } from './Jobs'
|
||
import { MiniRing, ScoredCandidateDetail, displayName } from './JobCandidates'
|
||
|
||
/* The department name as the hero line and the Details tab show it. Kept local
|
||
rather than imported from Jobs.jsx so this page does not break when that
|
||
screen's helpers are reshuffled. */
|
||
function deptValue(j) {
|
||
return String(j.requisitionDepartment || j.department || '').trim()
|
||
}
|
||
|
||
function deptLabel(j) {
|
||
return deptValue(j) || '—'
|
||
}
|
||
|
||
const TAB_KEYS = ['details', 'suggested', 'history']
|
||
|
||
const SORTS = [
|
||
{ value: 'score', label: 'Best Match → Worst Match' },
|
||
{ value: 'recent', label: 'Most Recent' },
|
||
{ value: 'name', label: 'A → Z' },
|
||
]
|
||
|
||
export default function JobProfile() {
|
||
const { jobId } = useParams()
|
||
const navigate = useNavigate()
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const { toast } = useToast()
|
||
const { can } = useAuth()
|
||
const qc = useQueryClient()
|
||
|
||
const canEdit = can('jobs.edit')
|
||
const canDelete = can('jobs.delete')
|
||
const [editing, setEditing] = useState(false)
|
||
|
||
const requestedTab = String(searchParams.get('tab') || '').toLowerCase()
|
||
const tab = TAB_KEYS.includes(requestedTab) ? requestedTab : 'details'
|
||
const setTab = (next) => {
|
||
const params = new URLSearchParams(searchParams)
|
||
if (next === 'details') params.delete('tab')
|
||
else params.set('tab', next)
|
||
setSearchParams(params, { replace: true })
|
||
}
|
||
|
||
// Suggested-list request params. They live on the page, not in the tab,
|
||
// because the one profile call carries them.
|
||
const [search, setSearch] = useState('')
|
||
const [top, setTop] = useState(DEFAULT_PAGE_SIZE)
|
||
const [skip, setSkip] = useState(0)
|
||
const params = { search: search.trim() || undefined, top, skip }
|
||
|
||
const profileQuery = useQuery({
|
||
queryKey: qk.jobs.profile(jobId, params),
|
||
queryFn: async () => (await jobsApi.fetchProfile(jobId, params))?.data ?? null,
|
||
enabled: Boolean(jobId),
|
||
retry: false,
|
||
// A new search or size must not blank the page already on screen.
|
||
placeholderData: keepPreviousData,
|
||
})
|
||
const profile = profileQuery.data
|
||
const job = useMemo(() => (profile?.job ? jobsApi.toJobView(profile.job) : null), [profile])
|
||
const suggested = useMemo(
|
||
() => (Array.isArray(profile?.candidates) ? profile.candidates.map(jobsApi.toSuggestedView) : []),
|
||
[profile],
|
||
)
|
||
|
||
const statusesQuery = useQuery({
|
||
queryKey: qk.jobs.requisitionStatuses(),
|
||
queryFn: async () => {
|
||
const res = await jobsApi.listRequisitionStatuses()
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return rows.length ? rows : jobsApi.REQUISITION_STATUSES
|
||
},
|
||
})
|
||
const statusLabels = (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label)
|
||
|
||
// Shared key documented on jobsApi.fetchDepartmentOptions — same fetcher everywhere.
|
||
const departmentsQuery = useQuery({
|
||
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||
queryFn: jobsApi.fetchDepartmentOptions,
|
||
enabled: editing,
|
||
})
|
||
|
||
const historyQuery = useQuery({
|
||
queryKey: qk.assignments.job(jobId),
|
||
queryFn: async () => {
|
||
const res = await assignmentsApi.listJob(jobId, { currentOnly: false })
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return rows.map((r) => assignmentsApi.toAssignmentView(r))
|
||
},
|
||
enabled: Boolean(jobId),
|
||
retry: false,
|
||
})
|
||
const statusQuery = useQuery({
|
||
queryKey: qk.jobs.statusHistory(jobId),
|
||
queryFn: async () => {
|
||
const res = await jobsApi.listStatusHistory(jobId)
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
enabled: Boolean(jobId),
|
||
retry: false,
|
||
})
|
||
const offersQuery = useQuery({
|
||
queryKey: qk.offers.list({ jobPostId: jobId, top: 200 }),
|
||
queryFn: async () => {
|
||
const res = await offersApi.list({ jobPostId: jobId, top: 200 })
|
||
return Array.isArray(res?.data) ? res.data : []
|
||
},
|
||
enabled: Boolean(jobId),
|
||
retry: false,
|
||
})
|
||
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
|
||
|
||
const updateJob = useMutation({
|
||
mutationFn: (body) => jobsApi.update(jobId, body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||
setEditing(false)
|
||
toast('Job updated', 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
|
||
})
|
||
|
||
const setJobStatus = useMutation({
|
||
mutationFn: (status) => jobsApi.setStatus(jobId, status),
|
||
onSuccess: (_d, status) => {
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
qc.invalidateQueries({ queryKey: qk.notifications.all() })
|
||
toast(`Status set to ${status}`, 'success')
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||
})
|
||
|
||
const deleteJob = useMutation({
|
||
mutationFn: () => jobsApi.remove(jobId),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||
toast('Job deleted', 'success')
|
||
navigate('/jobs', { replace: true })
|
||
},
|
||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
||
})
|
||
|
||
const goBack = () => (window.history.length > 1 ? navigate(-1) : navigate('/jobs'))
|
||
|
||
if (profileQuery.isPending || profileQuery.isError || !job) {
|
||
return (
|
||
<div className="cand-page">
|
||
<div className="cand-page-bar">
|
||
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
|
||
<div className="cand-page-crumb"><Link to="/jobs">Jobs</Link></div>
|
||
</div>
|
||
<div className="card">
|
||
<div className="card-body">
|
||
{profileQuery.isPending ? (
|
||
<SkeletonRows rows={6} />
|
||
) : (
|
||
<EmptyState icon="briefcase" title="Couldn’t load this job">
|
||
{friendlyAuthError(profileQuery.error, 'The job may have been deleted or is outside your scope.')}
|
||
</EmptyState>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const roleLine = [deptValue(job), job.location, job.type].filter(Boolean).join(' · ')
|
||
|
||
return (
|
||
<div className="cand-page">
|
||
<div className="cand-page-bar">
|
||
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
|
||
<div className="cand-page-crumb">
|
||
<Link to="/jobs">Jobs</Link> <span>›</span> <strong>{job.title}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-body">
|
||
<div className="profile-hero job-hero">
|
||
<span className="job-hero-icn"><Icon name="briefcase" /></span>
|
||
<div className="job-hero-id">
|
||
<div className="ph-name">{job.title}</div>
|
||
<div className="ph-role">{roleLine || '—'}</div>
|
||
<div className="ph-tags">
|
||
<Badge>{job.status}</Badge>
|
||
<Badge className="b-gray">{job.vacancies ?? 0} {job.vacancies === 1 ? 'Vacancy' : 'Vacancies'}</Badge>
|
||
<Badge className="b-gray">{job.applicantCount} {job.applicantCount === 1 ? 'Applicant' : 'Applicants'}</Badge>
|
||
</div>
|
||
</div>
|
||
<div className="job-hero-actions">
|
||
<div className="hero-stat">
|
||
<div className="v">{profile.total ?? profile.suggested ?? 0}</div>
|
||
<div className="l">Suggested</div>
|
||
</div>
|
||
<div
|
||
className="hero-stat"
|
||
title={profile.top_score != null ? `Candidates in the Strong Match band · best score ${profile.top_score}` : 'Candidates in the Strong Match band'}
|
||
>
|
||
<div className="v" style={{ color: profile.top_match ? 'var(--success)' : undefined }}>{profile.top_match ?? 0}</div>
|
||
<div className="l">Top Match</div>
|
||
</div>
|
||
{canEdit ? (
|
||
<select
|
||
className="select"
|
||
aria-label="Requisition status"
|
||
value={job.status}
|
||
disabled={setJobStatus.isPending}
|
||
onChange={(e) => setJobStatus.mutate(e.target.value)}
|
||
>
|
||
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||
</select>
|
||
) : null}
|
||
{canDelete && (
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ color: 'var(--danger)' }}
|
||
disabled={deleteJob.isPending}
|
||
onClick={() => { if (window.confirm(`Delete “${job.title}”?`)) deleteJob.mutate() }}
|
||
>
|
||
<Icon name="trash" /> {deleteJob.isPending ? 'Deleting…' : 'Delete'}
|
||
</button>
|
||
)}
|
||
{canEdit && (
|
||
<button className="btn btn-secondary" onClick={() => setEditing(true)}><Icon name="edit" /> Edit</button>
|
||
)}
|
||
<button className="btn btn-primary" onClick={() => navigate('/jobboard', { state: { publishJob: job.id } })}>
|
||
<Icon name="send" /> Publish
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs
|
||
value={tab}
|
||
onChange={setTab}
|
||
tabs={[
|
||
{ key: 'details', label: 'Details' },
|
||
{ key: 'suggested', label: 'Suggested Candidates', count: (profile.total ?? profile.suggested) || undefined },
|
||
{ key: 'history', label: 'History', count: historyCount || undefined },
|
||
]}
|
||
/>
|
||
|
||
<div className="tab-pane active">
|
||
{tab === 'details' && <JobDetailsTab job={job} canEdit={canEdit} />}
|
||
{tab === 'suggested' && (
|
||
<SuggestedCandidatesTab
|
||
job={job}
|
||
profile={profile}
|
||
candidates={suggested}
|
||
search={search}
|
||
setSearch={(v) => { setSearch(v); setSkip(0) }}
|
||
top={top}
|
||
skip={skip}
|
||
setSkip={setSkip}
|
||
setTop={(n) => {
|
||
const page = pageAfterSizeChange(Math.floor(skip / top) + 1, profile.total ?? 0, n)
|
||
setTop(n)
|
||
setSkip((page - 1) * n)
|
||
}}
|
||
/>
|
||
)}
|
||
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{editing && (
|
||
<EditJobForm
|
||
job={job}
|
||
departmentOptions={departmentsQuery.data ?? []}
|
||
busy={updateJob.isPending}
|
||
onClose={() => setEditing(false)}
|
||
onSubmit={(body) => updateJob.mutate(body)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function JobDetailsTab({ job: j, canEdit }) {
|
||
const created = [j.created ? fmtShort(j.created) : null, j.createdByName ? `by ${j.createdByName}` : null]
|
||
.filter(Boolean)
|
||
.join(' · ')
|
||
return (
|
||
<>
|
||
<JobCover jobId={j.id} />
|
||
|
||
<div className="info-grid mb-18">
|
||
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</div></div>
|
||
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
|
||
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
|
||
<div className="info-item"><div className="il">Platform</div><div className="iv">{platformLabel(j.platform) || '—'}</div></div>
|
||
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
|
||
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
||
<div className="info-item"><div className="il">Created</div><div className="iv">{created || '—'}</div></div>
|
||
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
|
||
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
||
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||
{j.closedAt && (
|
||
<div className="info-item"><div className="il">Closed at</div><div className="iv">{fmtShort(j.closedAt)}</div></div>
|
||
)}
|
||
</div>
|
||
|
||
<JobOwnership job={j} canEdit={canEdit} />
|
||
|
||
{j.description && (
|
||
<>
|
||
<div className="divider" />
|
||
<div className="mb-16">
|
||
<div style={SECTION_LABEL}>Description</div>
|
||
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
{j.skills.length > 0 && (
|
||
<div className="mb-16">
|
||
<div style={SECTION_LABEL}>Required Skills</div>
|
||
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
|
||
</div>
|
||
)}
|
||
{j.optionalSkills.length > 0 && (
|
||
<div>
|
||
<div style={SECTION_LABEL}>Optional Skills</div>
|
||
<div className="k-tags">
|
||
{j.optionalSkills.map((s) => (
|
||
<span className="tag tag-optional" key={s}><Icon name="star" /> {s}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function SuggestedCandidatesTab({ job, profile, candidates, search, setSearch, top, skip, setSkip, setTop }) {
|
||
const navigate = useNavigate()
|
||
const [band, setBand] = useState('')
|
||
const [sort, setSort] = useState('score')
|
||
const [viewing, setViewing] = useState(null)
|
||
|
||
// Search already ran on the server; band and sort apply to what came back.
|
||
const list = useMemo(() => {
|
||
let rows = candidates.filter((c) => !band || c.band === band)
|
||
if (sort === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name))
|
||
else if (sort === 'recent') rows = [...rows].sort((a, b) => (b.scoredAt?.getTime() ?? 0) - (a.scoredAt?.getTime() ?? 0))
|
||
else rows = [...rows].sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
|
||
return rows
|
||
}, [candidates, band, sort])
|
||
|
||
// Paging comes from the API: `total` counts every match, the page holds `top`.
|
||
const total = profile.total ?? candidates.length
|
||
const pages = Math.max(1, Math.ceil(total / top))
|
||
const page = Math.floor(skip / top) + 1
|
||
const from = total === 0 ? 0 : skip + 1
|
||
const to = Math.min(skip + candidates.length, total)
|
||
const bands = profile.bands || {}
|
||
|
||
function open(c) {
|
||
if (c.userId) {
|
||
navigate(`/candidate/${c.userId}`)
|
||
return
|
||
}
|
||
setViewing(c)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="cand-sub">
|
||
{total} candidate{total === 1 ? '' : 's'} suggested · vs {job.title}
|
||
{sort === 'score' && <span className="auto-tag">Sorted: Best → Worst</span>}
|
||
</div>
|
||
|
||
<div className="toolbar">
|
||
<div className="toolbar-search">
|
||
<Icon name="search" />
|
||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search name, email, title, company…" />
|
||
</div>
|
||
<select className="select" aria-label="Match band" value={band} onChange={(e) => setBand(e.target.value)}>
|
||
<option value="">All results</option>
|
||
{jobsApi.MATCH_BANDS.map((b) => (
|
||
<option key={b} value={b}>{b} ({bands[b] ?? 0})</option>
|
||
))}
|
||
</select>
|
||
<div className="spacer" />
|
||
<div className="flex items-center gap-8">
|
||
<label className="text-muted text-sm" htmlFor="suggested-sort">Sort:</label>
|
||
<select
|
||
id="suggested-sort"
|
||
className={`select${sort === 'score' ? ' active-filter' : ''}`}
|
||
value={sort}
|
||
onChange={(e) => setSort(e.target.value)}
|
||
>
|
||
{SORTS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{list.length === 0 ? (
|
||
candidates.length === 0 && !search.trim() ? (
|
||
<EmptyState icon="users" title="No suggested candidates yet">
|
||
Candidates appear here once their CVs are ATS-scored against this job.
|
||
</EmptyState>
|
||
) : (
|
||
<EmptyState title="No matches">Try a different search or band.</EmptyState>
|
||
)
|
||
) : (
|
||
<div className="grid g-3">
|
||
{list.map((c, i) => (
|
||
<SuggestedCard key={c.id} c={c} rank={sort === 'score' ? skip + i + 1 : null} onOpen={() => open(c)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{total > 0 && (
|
||
<Pagination
|
||
from={from}
|
||
to={to}
|
||
total={total}
|
||
page={page}
|
||
pages={pages}
|
||
setPage={(p) => setSkip((p - 1) * top)}
|
||
pageButtons={pageWindow(page, pages)}
|
||
pageSize={top}
|
||
pageSizeMax={100}
|
||
onPageSizeChange={setTop}
|
||
/>
|
||
)}
|
||
|
||
{viewing && (
|
||
<ScoredCandidateDetail
|
||
candidate={{
|
||
id: viewing.id,
|
||
name: viewing.name,
|
||
filename: viewing.email,
|
||
source: viewing.sourceLabel,
|
||
currentTitle: viewing.currentTitle,
|
||
currentCompany: viewing.currentCompany,
|
||
experience: viewing.experience,
|
||
aiScore: viewing.score,
|
||
matchedSkills: viewing.matchedSkills,
|
||
missingSkills: viewing.missingSkills,
|
||
critique: viewing.summary,
|
||
scoringStatus: 'completed',
|
||
applied: viewing.scoredAt,
|
||
}}
|
||
jobTitle={job.title}
|
||
onClose={() => setViewing(null)}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function SuggestedCard({ c, rank, onOpen }) {
|
||
const matched = c.matchedSkills.slice(0, 3)
|
||
const missing = c.missingSkills.slice(0, 2)
|
||
const optional = c.optionalMatched.slice(0, 3)
|
||
const more = (c.matchedSkills.length - matched.length)
|
||
+ (c.missingSkills.length - missing.length)
|
||
+ (c.optionalMatched.length - optional.length)
|
||
const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ')
|
||
const foot = [c.experience != null ? `${c.experience} yrs` : null, c.currentCompany].filter(Boolean).join(' · ')
|
||
|
||
return (
|
||
<div
|
||
className={`card cand-card${rank ? ' ranked' : ''}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={onOpen}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen() } }}
|
||
>
|
||
<div className="card-body">
|
||
{rank && <span className="cand-rank">{rank}</span>}
|
||
<div className="cand-head">
|
||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||
<div className="cand-id">
|
||
<div className="cand-name">{displayName(c.name)}</div>
|
||
<div className="cand-role">{roleLine || c.email || '—'}</div>
|
||
</div>
|
||
{c.score != null && <MiniRing score={c.score} />}
|
||
</div>
|
||
|
||
<div className="cand-skills">
|
||
{matched.map((s) => <span className="cand-chip ok" key={`m-${s}`}><Icon name="check" /> {s}</span>)}
|
||
{missing.map((s) => <span className="cand-chip miss" key={`x-${s}`}><Icon name="x" /> {s}</span>)}
|
||
{optional.map((s) => (
|
||
<span className="cand-chip opt" key={`o-${s}`} title="Optional skill from the job post"><Icon name="star" /> {s}</span>
|
||
))}
|
||
{more > 0 && <span className="cand-chip more">+{more} more</span>}
|
||
</div>
|
||
|
||
<p className="cand-crit">{c.summary || <span className="text-muted">No summary</span>}</p>
|
||
|
||
<div className="cand-foot">
|
||
<span className="cand-company">{foot || '—'}</span>
|
||
<Badge className="b-gray">{c.sourceLabel}</Badge>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|