total applications removed
parent
25220d237b
commit
c0e4a94d58
|
|
@ -14,9 +14,12 @@ router = APIRouter()
|
|||
|
||||
|
||||
class OfferCreate(BaseModel):
|
||||
inbox_id: int
|
||||
offer_id: str | None = None
|
||||
inbox_id: int | None = None
|
||||
manual_upload_candidate_id: str | None = None
|
||||
form_data_id: str | None = None
|
||||
job_post_id: str
|
||||
candidate_user_id: str
|
||||
candidate_user_id: str | None = None
|
||||
status: str | None = "draft"
|
||||
base_salary: float | None = None
|
||||
currency: str | None = None
|
||||
|
|
@ -74,9 +77,9 @@ class OfferSent(BaseModel):
|
|||
inbox_id: int | None = None
|
||||
manual_upload_candidate_id: str | None = None
|
||||
form_data_id: str | None = None
|
||||
job_post_id: str
|
||||
job_post_id: str | None = None
|
||||
candidate_user_id: str | None = None
|
||||
base_salary: float
|
||||
base_salary: float | None = None
|
||||
currency: str | None = "USD"
|
||||
salary_period: str | None = "year"
|
||||
signing_bonus: float | None = None
|
||||
|
|
|
|||
|
|
@ -183,6 +183,20 @@ class Offers(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_draft_or_failed_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id):
|
||||
uid = cls._as_uuid(candidate_user_id)
|
||||
jid = cls._as_uuid(job_post_id)
|
||||
if uid is None or jid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(cls.candidate_user_id == uid, cls.job_post_id == jid)
|
||||
.where(cls.status.in_(("draft", "failed")))
|
||||
.order_by(cls.created_at.desc())
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
class OfferStatusHistory(SQLModel, table=True):
|
||||
__tablename__ = "offer_status_history"
|
||||
|
|
|
|||
|
|
@ -219,39 +219,75 @@ class Offer:
|
|||
existing["name"]=item.get("name")
|
||||
|
||||
async def create_offer(self,payload,current_user):
|
||||
if not payload.get("inbox_id"):
|
||||
raise HTTPException(status_code=422,detail="inbox_id is required")
|
||||
job_post_id=_as_uuid(payload.get("job_post_id"))
|
||||
if job_post_id is None:
|
||||
raise HTTPException(status_code=422,detail="job_post_id is required")
|
||||
candidate_user_id=_as_uuid(payload.get("candidate_user_id"))
|
||||
if candidate_user_id is None:
|
||||
raise HTTPException(status_code=422,detail="candidate_user_id is required")
|
||||
created_by=_user_id(current_user)
|
||||
status=payload.get("status") or "draft"
|
||||
app=await self._resolve_application(payload)
|
||||
await self._assert_offer_job(current_user,app["job_post_id"])
|
||||
candidate_user_id=app["candidate_user_id"]
|
||||
job_post_id=app["job_post_id"]
|
||||
open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id)
|
||||
retry_id=_as_uuid(payload.get("offer_id"))
|
||||
if open_row and (retry_id is None or open_row.id!=retry_id):
|
||||
raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job")
|
||||
|
||||
fields={
|
||||
"inbox_id": int(payload["inbox_id"]),
|
||||
fields=_comp_fields(payload)
|
||||
fields.update({
|
||||
"inbox_id": app.get("inbox_id"),
|
||||
"manual_upload_candidate_id": app.get("manual_upload_id"),
|
||||
"form_data_id": app.get("form_data_id"),
|
||||
"job_post_id": job_post_id,
|
||||
"candidate_user_id": candidate_user_id,
|
||||
"created_by": created_by,
|
||||
"status": status,
|
||||
}
|
||||
for key in non_validation_values():
|
||||
if key in payload and payload[key] is not None:
|
||||
fields[key]=payload[key]
|
||||
"status": "draft",
|
||||
})
|
||||
if fields.get("equity_units") in (None,0):
|
||||
fields["equity_instrument"]=None
|
||||
|
||||
if retry_id is not None:
|
||||
row=await Offers.get_offer_by_id(self.session,retry_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Offer not found")
|
||||
if row.status in ("sent","negotiating"):
|
||||
raise HTTPException(status_code=409,detail="This offer has already been sent")
|
||||
from_status=row.status
|
||||
row=await Offers.update_offer(self.session,retry_id,fields)
|
||||
if from_status!="draft":
|
||||
await OfferStatusHistory.insert_history(self.session,{
|
||||
"offer_id": row.id,
|
||||
"from_status": from_status,
|
||||
"to_status": "draft",
|
||||
"changed_by": created_by,
|
||||
"actor_kind": "user",
|
||||
"change_reason": payload.get("change_reason") or "saved",
|
||||
})
|
||||
return (await self._hydrate_offers([row]))[0]
|
||||
|
||||
existing=await Offers.get_draft_or_failed_for_candidate_job(
|
||||
self.session,candidate_user_id,job_post_id,
|
||||
)
|
||||
if existing:
|
||||
from_status=existing.status
|
||||
row=await Offers.update_offer(self.session,existing.id,fields)
|
||||
if from_status!="draft":
|
||||
await OfferStatusHistory.insert_history(self.session,{
|
||||
"offer_id": row.id,
|
||||
"from_status": from_status,
|
||||
"to_status": "draft",
|
||||
"changed_by": created_by,
|
||||
"actor_kind": "user",
|
||||
"change_reason": payload.get("change_reason") or "saved",
|
||||
})
|
||||
return (await self._hydrate_offers([row]))[0]
|
||||
|
||||
fields["created_by"]=created_by
|
||||
row=await Offers.insert_offer(self.session,fields)
|
||||
history_data={
|
||||
await OfferStatusHistory.insert_history(self.session,{
|
||||
"offer_id": row.id,
|
||||
"from_status": None,
|
||||
"to_status": status,
|
||||
"to_status": "draft",
|
||||
"changed_by": created_by,
|
||||
"actor_kind": "user",
|
||||
"change_reason": payload.get("change_reason"),
|
||||
}
|
||||
await OfferStatusHistory.insert_history(self.session,history_data)
|
||||
return serialize_offer(row)
|
||||
})
|
||||
return (await self._hydrate_offers([row]))[0]
|
||||
|
||||
async def update_offer(self,offer_id,payload,current_user):
|
||||
row=await Offers.get_offer_by_id(self.session,offer_id)
|
||||
|
|
@ -322,6 +358,7 @@ class Offer:
|
|||
|
||||
async def send_offer(self,payload,current_user):
|
||||
created_by=_user_id(current_user)
|
||||
payload=await self._payload_for_send(payload)
|
||||
app=await self._resolve_application(payload)
|
||||
await self._assert_offer_job(current_user,app["job_post_id"])
|
||||
candidate_user_id=app["candidate_user_id"]
|
||||
|
|
@ -331,7 +368,10 @@ class Offer:
|
|||
|
||||
open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id)
|
||||
retry_id=_as_uuid(payload.get("offer_id"))
|
||||
if open_row and (retry_id is None or open_row.id!=retry_id):
|
||||
resend=False
|
||||
if open_row and retry_id is not None and open_row.id==retry_id:
|
||||
resend=open_row.status in ("sent","negotiating")
|
||||
elif open_row and (retry_id is None or open_row.id!=retry_id):
|
||||
raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job")
|
||||
|
||||
fields=_comp_fields(payload)
|
||||
|
|
@ -341,8 +381,9 @@ class Offer:
|
|||
"form_data_id": app.get("form_data_id"),
|
||||
"job_post_id": job_post_id,
|
||||
"candidate_user_id": candidate_user_id,
|
||||
"status": "failed",
|
||||
})
|
||||
if not resend:
|
||||
fields["status"]="draft"
|
||||
if fields.get("equity_units") in (None,0):
|
||||
fields["equity_instrument"]=None
|
||||
|
||||
|
|
@ -351,20 +392,23 @@ class Offer:
|
|||
row=await Offers.get_offer_by_id(self.session,retry_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Offer not found")
|
||||
if row.status in ("sent","negotiating"):
|
||||
raise HTTPException(status_code=409,detail="This offer has already been sent")
|
||||
if row.status in ("accepted","declined"):
|
||||
raise HTTPException(status_code=409,detail="This offer is already closed")
|
||||
row=await Offers.update_offer(self.session,retry_id,fields)
|
||||
else:
|
||||
failed=await Offers.get_failed_for_candidate_job(self.session,candidate_user_id,job_post_id)
|
||||
if failed:
|
||||
row=await Offers.update_offer(self.session,failed.id,fields)
|
||||
existing=await Offers.get_draft_or_failed_for_candidate_job(
|
||||
self.session,candidate_user_id,job_post_id,
|
||||
)
|
||||
if existing:
|
||||
row=await Offers.update_offer(self.session,existing.id,fields)
|
||||
else:
|
||||
fields["created_by"]=created_by
|
||||
fields["status"]="draft"
|
||||
row=await Offers.insert_offer(self.session,fields)
|
||||
await OfferStatusHistory.insert_history(self.session,{
|
||||
"offer_id": row.id,
|
||||
"from_status": None,
|
||||
"to_status": "failed",
|
||||
"to_status": "draft",
|
||||
"changed_by": created_by,
|
||||
"actor_kind": "user",
|
||||
"change_reason": payload.get("change_reason"),
|
||||
|
|
@ -383,6 +427,9 @@ class Offer:
|
|||
raise RuntimeError("candidate has no email")
|
||||
await send_offer_mail(email,subject,html)
|
||||
except (httpx.HTTPError,RuntimeError) as e:
|
||||
if row.status not in ("sent","negotiating"):
|
||||
await Offers.update_offer(self.session,row.id,{"status":"failed"})
|
||||
row=await Offers.get_offer_by_id(self.session,row.id)
|
||||
await self._notify_send_failed(created_by,name,job_title,row)
|
||||
raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e
|
||||
|
||||
|
|
@ -416,6 +463,33 @@ class Offer:
|
|||
)
|
||||
return (await self._hydrate_offers([updated]))[0]
|
||||
|
||||
async def _payload_for_send(self,payload):
|
||||
data=dict(payload or {})
|
||||
offer_id=_as_uuid(data.get("offer_id"))
|
||||
if offer_id is None:
|
||||
return data
|
||||
row=await Offers.get_offer_by_id(self.session,offer_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Offer not found")
|
||||
if data.get("inbox_id") in (None,"") and not data.get("manual_upload_candidate_id") and not data.get("form_data_id"):
|
||||
if row.inbox_id is not None:
|
||||
data["inbox_id"]=row.inbox_id
|
||||
elif row.manual_upload_candidate_id is not None:
|
||||
data["manual_upload_candidate_id"]=str(row.manual_upload_candidate_id)
|
||||
elif row.form_data_id is not None:
|
||||
data["form_data_id"]=str(row.form_data_id)
|
||||
if data.get("job_post_id") in (None,""):
|
||||
data["job_post_id"]=str(row.job_post_id) if row.job_post_id else None
|
||||
if data.get("candidate_user_id") in (None,""):
|
||||
data["candidate_user_id"]=str(row.candidate_user_id) if row.candidate_user_id else None
|
||||
if data.get("base_salary") in (None,""):
|
||||
data["base_salary"]=row.base_salary
|
||||
for key in ("currency","salary_period","signing_bonus","annual_bonus_pct",
|
||||
"equity_units","equity_instrument","start_date","expiry_date"):
|
||||
if key not in data or data.get(key) in (None,""):
|
||||
data[key]=getattr(row,key)
|
||||
return data
|
||||
|
||||
async def _resolve_application(self,payload):
|
||||
inbox_id=payload.get("inbox_id")
|
||||
manual_id=_as_uuid(payload.get("manual_upload_candidate_id"))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { toDate } from '../lib/format'
|
|||
recruiter who can read offers still cannot issue one.
|
||||
|
||||
List rows include candidate_name / created_by_name. Job title is still
|
||||
hydrated from /job/fetch?ids=. Create Offer uses POST /offers/jobs/sent.
|
||||
hydrated from /job/fetch?ids=. Create Offer saves POST /offers/create
|
||||
(draft). Send on the list is POST /offers/jobs/sent.
|
||||
============================================================ */
|
||||
|
||||
/** `status` is a free-text column defaulting to "draft"; the vocabulary is decided here. */
|
||||
|
|
|
|||
|
|
@ -205,7 +205,8 @@ export function hrefForPreviousApplication(item) {
|
|||
/** Full application list for profile / inbox / add-candidate. */
|
||||
export function PreviousApplications({ row, title = 'Total applications' }) {
|
||||
const items = candidateApplicationsOf(row)
|
||||
if (!items.length) return null
|
||||
// One row is the application already on screen — do not show a history card.
|
||||
if (items.length < 2) return null
|
||||
const current = currentRowIds(row)
|
||||
const heading = title === 'Total applications' ? `Total applications (${items.length})` : title
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
/* ============================================================
|
||||
Offers — live on backend/offer/app.py.
|
||||
|
||||
Read is GET /offers/fetch. Create Offer writes POST /offers/jobs/sent
|
||||
(persist, email via Teams, then pipeline OFFER). Failed sends stay listed;
|
||||
GET /offers/fetch?offer_id= prefills the form for retry. Drafts still use
|
||||
POST /offers/create (Candidate Forms) and POST /offers/issue.
|
||||
|
||||
HYDRATION, NOT N+1. serialize_offer now includes candidate_name when listing;
|
||||
job titles still come from /job/fetch?ids=. Equity is `equity_units` +
|
||||
`equity_instrument` server-side.
|
||||
Create Offer writes POST /offers/create as a draft (no email). Send on the
|
||||
list (and offer detail) writes POST /offers/jobs/sent: Teams mail, then
|
||||
pipeline OFFER. Failed sends stay listed for retry from the row Send button.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
|
|
@ -144,13 +139,27 @@ export default function Offers() {
|
|||
}
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: (offerId) => offersApi.issue(offerId),
|
||||
onSuccess: (_res, _id) => {
|
||||
const save = useMutation({
|
||||
mutationFn: (body) => offersApi.create(body),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
toast('Offer issued and marked sent', 'success')
|
||||
setCreating(null)
|
||||
toast('Offer saved — review it on the list, then Send', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save the offer.'), 'error'),
|
||||
})
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: (body) => offersApi.send(body),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setViewing(null)
|
||||
toast('Offer emailed and moved to Offer stage', 'success')
|
||||
},
|
||||
onError: (err) => {
|
||||
invalidate()
|
||||
toast(friendlyAuthError(err, 'Failed: the offer could not be sent'), 'error')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not issue the offer.'), 'error'),
|
||||
})
|
||||
|
||||
const setStatus = useMutation({
|
||||
|
|
@ -171,20 +180,7 @@ export default function Offers() {
|
|||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'),
|
||||
})
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: (body) => offersApi.send(body),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setCreating(null)
|
||||
toast('Offer emailed and moved to Offer stage', 'success')
|
||||
},
|
||||
onError: (err) => {
|
||||
invalidate()
|
||||
toast(friendlyAuthError(err, 'Failed: the offer could not be sent'), 'error')
|
||||
},
|
||||
})
|
||||
|
||||
const busy = issue.isPending || setStatus.isPending
|
||||
const busy = send.isPending || setStatus.isPending
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -225,17 +221,15 @@ export default function Offers() {
|
|||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (o) => (
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => (
|
||||
o.status === 'failed' ? setCreating({ offerId: o.id }) : setViewing(o)
|
||||
)}>
|
||||
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip={o.status === 'failed' ? 'Edit and resend' : o.status === 'draft' ? 'Send offer' : 'Resend'}
|
||||
aria-label={o.status === 'failed' ? 'Edit and resend offer' : o.status === 'draft' ? 'Send offer' : 'Resend offer'}
|
||||
data-tip={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend'}
|
||||
aria-label={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend offer'}
|
||||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||||
onClick={() => (o.status === 'failed' ? setCreating({ offerId: o.id }) : issue.mutate(o.id))}
|
||||
onClick={() => send.mutate(sendPayloadFromOffer(o))}
|
||||
>
|
||||
<Icon name="send" />
|
||||
</button>
|
||||
|
|
@ -306,24 +300,24 @@ export default function Offers() {
|
|||
offer={viewing}
|
||||
busy={busy}
|
||||
onClose={() => setViewing(null)}
|
||||
onIssue={() => issue.mutate(viewing.id)}
|
||||
onRetry={() => { setViewing(null); setCreating({ offerId: viewing.id }) }}
|
||||
onSend={() => send.mutate(sendPayloadFromOffer(viewing))}
|
||||
onEdit={() => { setViewing(null); setCreating({ offerId: viewing.id }) }}
|
||||
onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }}
|
||||
/>
|
||||
)}
|
||||
{creating && (
|
||||
<CreateOffer
|
||||
offerId={creating.offerId || null}
|
||||
busy={send.isPending}
|
||||
busy={save.isPending}
|
||||
onClose={() => setCreating(null)}
|
||||
onSubmit={(body) => send.mutate(body)}
|
||||
onSubmit={(body) => save.mutate(body)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) {
|
||||
function OfferDetail({ offer: o, busy, onClose, onSend, onEdit, onStatus }) {
|
||||
/* Est. total cash = base + the bonus percentage applied to it. Signing bonus
|
||||
is a one-off and is shown separately rather than folded in, because adding
|
||||
it would overstate year two. */
|
||||
|
|
@ -346,19 +340,16 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) {
|
|||
Mark {OFFER_STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
{o.status === 'failed' ? (
|
||||
<button className="btn btn-primary" disabled={busy} onClick={onRetry}>
|
||||
<Icon name="send" /> Edit and resend
|
||||
<button className="btn btn-secondary" disabled={busy} onClick={onEdit}>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||||
onClick={() => { onIssue(); onClose() }}
|
||||
onClick={onSend}
|
||||
>
|
||||
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
|
||||
<Icon name="send" /> {o.status === 'draft' || o.status === 'failed' ? 'Send Offer' : 'Resend Offer'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
@ -435,6 +426,28 @@ function applicationIdentity(c) {
|
|||
return {}
|
||||
}
|
||||
|
||||
function sendPayloadFromOffer(o) {
|
||||
return {
|
||||
offer_id: o.id,
|
||||
...applicationIdentity({
|
||||
inbox_id: o.inboxId,
|
||||
manual_upload_candidate_id: o.manualUploadId,
|
||||
form_data_id: o.formDataId,
|
||||
}),
|
||||
job_post_id: o.jobPostId,
|
||||
candidate_user_id: o.candidateUserId || undefined,
|
||||
base_salary: o.base,
|
||||
currency: o.currency,
|
||||
salary_period: o.salaryPeriod,
|
||||
annual_bonus_pct: o.bonusPct,
|
||||
signing_bonus: o.signingBonus,
|
||||
equity_units: o.equityUnits,
|
||||
equity_instrument: o.equityInstrument,
|
||||
start_date: localDateIso(toDateInput(o.startDate)),
|
||||
expiry_date: localDateIso(toDateInput(o.expiry)),
|
||||
}
|
||||
}
|
||||
|
||||
function localDateIso(date) {
|
||||
if (!date) return null
|
||||
const d = new Date(`${date}T00:00`)
|
||||
|
|
@ -658,6 +671,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
|
|||
...identity,
|
||||
job_post_id: selected.job_post_id,
|
||||
candidate_user_id: selected.user_id || undefined,
|
||||
status: 'draft',
|
||||
base_salary: base,
|
||||
currency: form.currency,
|
||||
salary_period: form.salaryPeriod,
|
||||
|
|
@ -676,14 +690,14 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
|
|||
|
||||
return (
|
||||
<Modal
|
||||
title={offerId ? 'Resend Offer' : 'Create Offer'}
|
||||
subtitle={offerId ? 'Edit compensation and send again' : 'Emails the candidate and moves them to Offer'}
|
||||
title={offerId ? 'Edit Offer' : 'Create Offer'}
|
||||
subtitle={offerId ? 'Update compensation and save — send from the list' : 'Saved as a draft — send from the Offers list after you review it'}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
|
||||
<Icon name="send" /> {busy ? 'Sending…' : 'Send Offer'}
|
||||
<Icon name="check" /> {busy ? 'Saving…' : 'Save Offer'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
|
|
@ -781,7 +795,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
|
|||
|
||||
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
|
||||
Equity is stored as a unit count plus an instrument, so “20k RSU” is entered as 20000 and RSU.
|
||||
Sending emails the candidate and moves the pipeline to Offer only after the mail succeeds.
|
||||
This saves a draft only. Use Send on the Offers list after you review it — that emails the candidate and moves them to Offer.
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
Loading…
Reference in New Issue