frontend all aplications
parent
52b76bb1cf
commit
3ae716a8c4
|
|
@ -1,11 +1,9 @@
|
|||
from typing import Any
|
||||
from fastapi import APIRouter,Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import HTTPException
|
||||
from db_setup import get_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from inbox.views import Email
|
||||
import uuid
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
|
@ -116,14 +114,22 @@ async def get_inbox_read_status(
|
|||
|
||||
@router.get("/inbox/all-applications")
|
||||
async def get_all_applications(
|
||||
app_id:uuid.UUID|int=Query(None),
|
||||
current_user:dict=Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
||||
session:AsyncSession=Depends(get_session),
|
||||
record_id: str | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
top: int | None = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.get_all_applications(app_id)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
if record_id:
|
||||
item=await service.get_application_by_id(record_id)
|
||||
return JSONResponse(content={"data":item,"total":1,"status_code":200})
|
||||
|
||||
items=await service.get_all_applications(top,skip,search)
|
||||
total=await service.count_inbox_messages(search)
|
||||
return JSONResponse(content={"data":items,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -2,9 +2,19 @@ from pathlib import Path
|
|||
|
||||
from inbox.models import Inbox_Messages
|
||||
|
||||
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
|
||||
_RESUME_STATUS = {
|
||||
"processing": "Parsing",
|
||||
"matched": "Parsed",
|
||||
"no_text": "Failed",
|
||||
"failed": "Failed",
|
||||
"dlq": "Failed",
|
||||
"skipped": "Pending",
|
||||
}
|
||||
|
||||
def serialize_message(message: Inbox_Messages) -> dict:
|
||||
"""inbox_messages row -> the shape the #inbox Email tab renders."""
|
||||
|
||||
def _sender_name(message: Inbox_Messages) -> str:
|
||||
"""Graph's display name when the payload carries one, else the raw address."""
|
||||
sender_name = message.message_from
|
||||
full = message.full_email_response
|
||||
if isinstance(full, dict):
|
||||
|
|
@ -15,12 +25,21 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
|||
name = email_address.get("name")
|
||||
if name:
|
||||
sender_name = name
|
||||
return sender_name
|
||||
|
||||
attachment_name = None
|
||||
|
||||
def _attachment_name(message: Inbox_Messages) -> str | None:
|
||||
if message.file_name:
|
||||
attachment_name = message.file_name.split(",")[0].strip() or None
|
||||
elif message.file_path:
|
||||
attachment_name = Path(message.file_path.split(",")[0].strip()).name or None
|
||||
return message.file_name.split(",")[0].strip() or None
|
||||
if message.file_path:
|
||||
return Path(message.file_path.split(",")[0].strip()).name or None
|
||||
return None
|
||||
|
||||
|
||||
def serialize_message(message: Inbox_Messages) -> dict:
|
||||
"""inbox_messages row -> the shape the #inbox Email tab renders."""
|
||||
sender_name = _sender_name(message)
|
||||
attachment_name = _attachment_name(message)
|
||||
|
||||
return {
|
||||
"id": str(message.id),
|
||||
|
|
@ -47,3 +66,36 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
|||
"match_error": message.match_error,
|
||||
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_application(message: Inbox_Messages) -> dict:
|
||||
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
|
||||
|
||||
`position` is the mail subject and `source` is the To address, which is where
|
||||
the board tag (Rozee, Mustakbil, Employee Referral, ...) lands.
|
||||
|
||||
The tab also wants ats_score, phone, experience, recruiter, duplicate and a
|
||||
processing state beyond read/unread. inbox_messages has no columns for any of
|
||||
those, so they come back null instead of invented — see the note in
|
||||
inbox/file_decoder.py. `processing` is derived from message_read alone, so it
|
||||
is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
|
||||
"""
|
||||
return {
|
||||
"id": str(message.id),
|
||||
"name": _sender_name(message),
|
||||
"email": message.message_from,
|
||||
"position": message.message_subject,
|
||||
"source": message.message_to,
|
||||
"received": message.message_received_time,
|
||||
"unread": not message.message_read,
|
||||
"processing": "Read" if message.message_read else "Unread",
|
||||
"resume_status": _RESUME_STATUS.get(message.match_status, "Pending"),
|
||||
"attachment": _attachment_name(message),
|
||||
"has_attachment": message.attachment,
|
||||
"resume_text": message.resume_text,
|
||||
"ats_score": None,
|
||||
"phone": None,
|
||||
"experience": None,
|
||||
"recruiter": None,
|
||||
"duplicate": None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import httpx,os
|
|||
from fastapi import HTTPException
|
||||
from inbox.models import Inbox_Messages
|
||||
from inbox.file_decoder import decode_attachment
|
||||
from inbox.serializers import serialize_message
|
||||
from inbox.serializers import serialize_application, serialize_message
|
||||
from inbox.plugins import (
|
||||
EMAIL_API_TOKEN,
|
||||
fetch_message_read_status,
|
||||
|
|
@ -91,6 +91,16 @@ class Email:
|
|||
item["files"]=files
|
||||
return item
|
||||
|
||||
async def get_all_applications(self,top,skip,search=None):
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search)
|
||||
return [serialize_application(m) for m in messages]
|
||||
|
||||
async def get_application_by_id(self,record_id):
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
if not message:
|
||||
raise HTTPException(status_code=404,detail="Application not found")
|
||||
return serialize_application(message)
|
||||
|
||||
async def queue_rematch(self,record_id):
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
if not message:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ export function listMessages() {
|
|||
return request('/inbox/fetch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted applications — the shape the All Applications tab renders.
|
||||
*
|
||||
* Unlike /inbox/fetch this one IS permissioned server-side
|
||||
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
|
||||
*/
|
||||
export function listApplications({ search, top, skip, recordId } = {}) {
|
||||
return request('/inbox/all-applications', {
|
||||
params: { search, top, skip, record_id: recordId },
|
||||
})
|
||||
}
|
||||
|
||||
/** Triggers the Graph proxy to pull new mail and persist it. */
|
||||
export function syncMailbox({ token, top, skip } = {}) {
|
||||
return request('/email/fetch', { params: { token, top, skip } })
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const qk = {
|
|||
mailbox: {
|
||||
all: () => ['mailbox'],
|
||||
messages: () => ['mailbox', 'messages'],
|
||||
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||
},
|
||||
|
||||
// --- seed-backed buckets ---
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as inboxApi from '../api/inbox'
|
||||
import {
|
||||
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
|
||||
initials as initialsOf, int, locations, pick, relTime, skillsPool, TODAY,
|
||||
initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta,
|
||||
TODAY,
|
||||
} from '../data/seed'
|
||||
|
||||
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
|
||||
|
|
@ -51,22 +52,20 @@ function parseDate(value) {
|
|||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function resumeText(i) {
|
||||
return `${i.name.toUpperCase()}
|
||||
${i.email} · ${i.phone}
|
||||
${'—'.repeat(30)}
|
||||
PROFESSIONAL SUMMARY
|
||||
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
|
||||
|
||||
EXPERIENCE
|
||||
• ${pick(companies)} — Senior role (2021–Present)
|
||||
• ${pick(companies)} — Associate (2018–2021)
|
||||
|
||||
EDUCATION
|
||||
• Bachelor's Degree, Computer Science
|
||||
|
||||
SKILLS
|
||||
• ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
|
||||
/**
|
||||
* `source` arrives as the raw To address, because that is where the board tag
|
||||
* lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
|
||||
* everything but letters from both sides so "Employee Referral" still matches
|
||||
* "employee-referral@", and keep the brand colour SourceChip paints from.
|
||||
* Nothing matches -> show the first recipient verbatim rather than guess.
|
||||
*/
|
||||
function sourceFrom(messageTo) {
|
||||
const raw = (messageTo || '').trim()
|
||||
if (!raw) return { source: 'Unknown', sourceMeta: null }
|
||||
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
|
||||
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
|
||||
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
|
||||
return { source: raw.split(',')[0].trim(), sourceMeta: null }
|
||||
}
|
||||
|
||||
function SourceChip({ item }) {
|
||||
|
|
@ -83,7 +82,7 @@ function SourceChip({ item }) {
|
|||
export default function Inbox() {
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
|
||||
const qc = useQueryClient()
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const updateInbox = useSeedMutation('inbox')
|
||||
|
|
@ -96,6 +95,48 @@ export default function Inbox() {
|
|||
const [assigning, setAssigning] = useState(null)
|
||||
const [noting, setNoting] = useState(null)
|
||||
|
||||
/**
|
||||
* GET /inbox/all-applications. READ-ONLY: inbox_messages has no columns for
|
||||
* processing state, duplicates, recruiter, phone, experience or an ATS score,
|
||||
* so those arrive null and every mutating action on this tab is disabled until
|
||||
* the endpoints exist. `processing` is derived from message_read alone, which
|
||||
* is why the Imported / Processed / Rejected / Duplicates tabs read empty.
|
||||
*/
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: qk.mailbox.applications(),
|
||||
queryFn: async () => {
|
||||
const res = await inboxApi.listApplications()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((row) => {
|
||||
const name = row.name || row.email || 'Unknown'
|
||||
return {
|
||||
id: String(row.id),
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
email: row.email || '',
|
||||
position: row.position || '(no subject)',
|
||||
...sourceFrom(row.source),
|
||||
received: parseDate(row.received),
|
||||
unread: Boolean(row.unread),
|
||||
processing: row.processing || 'Unread',
|
||||
resumeStatus: row.resume_status || 'Pending',
|
||||
attachment: row.attachment,
|
||||
hasAttachment: Boolean(row.has_attachment),
|
||||
resumeText: row.resume_text || '',
|
||||
atsScore: row.ats_score,
|
||||
phone: row.phone,
|
||||
experience: row.experience,
|
||||
recruiter: row.recruiter,
|
||||
duplicate: Boolean(row.duplicate),
|
||||
}
|
||||
})
|
||||
},
|
||||
enabled: tab !== 'Email',
|
||||
})
|
||||
|
||||
const inbox = applicationsQuery.data ?? []
|
||||
|
||||
const emailsQuery = useQuery({
|
||||
queryKey: qk.mailbox.messages(),
|
||||
queryFn: async () => {
|
||||
|
|
@ -151,9 +192,20 @@ export default function Inbox() {
|
|||
|
||||
const selected = inbox.find((i) => i.id === selectedId)
|
||||
|
||||
// The one mutation this tab CAN persist. Note it needs INBOX_EDIT while the
|
||||
// list only needs INBOX_VIEW, so a view-only user gets a 403 here.
|
||||
const markApplicationRead = useMutation({
|
||||
mutationFn: (recordId) => inboxApi.markRead(recordId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not mark application read.'), 'error'),
|
||||
})
|
||||
|
||||
function select(id) {
|
||||
setSelectedId(id)
|
||||
updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i)))
|
||||
const item = inbox.find((i) => i.id === id)
|
||||
if (item?.unread) markApplicationRead.mutate(id)
|
||||
}
|
||||
|
||||
function makeCandidate(item, job, cs) {
|
||||
|
|
@ -250,7 +302,15 @@ export default function Inbox() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{list.length === 0 ? (
|
||||
{applicationsQuery.isPending && (
|
||||
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
|
||||
)}
|
||||
{applicationsQuery.isError && (
|
||||
<EmptyState icon="inbox" title="Couldn’t load applications">
|
||||
{friendlyAuthError(applicationsQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{applicationsQuery.isSuccess && list.length === 0 ? (
|
||||
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
|
||||
) : (
|
||||
list.map((i) => (
|
||||
|
|
@ -271,8 +331,15 @@ export default function Inbox() {
|
|||
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div>
|
||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||
<div className="ii-time">
|
||||
{i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'}
|
||||
</div>
|
||||
{/* No ATS score exists server-side — the agent returns a
|
||||
verdict, not a number. The chip stays off rather than
|
||||
rendering a placeholder that reads as a real score. */}
|
||||
{i.atsScore != null && (
|
||||
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
@ -316,13 +383,17 @@ export default function Inbox() {
|
|||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
|
||||
disabled
|
||||
title="Needs a backend endpoint — not implemented yet"
|
||||
>
|
||||
<Icon name="user-plus" /> Import Candidate
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>{resumeText(previewing)}</pre>
|
||||
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>
|
||||
{previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
|
||||
</pre>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
|
|
@ -363,9 +434,17 @@ export default function Inbox() {
|
|||
)
|
||||
}
|
||||
|
||||
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */
|
||||
function orDash(value, suffix = '') {
|
||||
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||||
}
|
||||
|
||||
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
|
||||
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||||
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||||
// Every action below writes to a table column or an endpoint that does not
|
||||
// exist yet, so they are disabled rather than silently dropping the click.
|
||||
const noBackend = 'Needs a backend endpoint — not implemented yet'
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
|
|
@ -381,43 +460,72 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
|
|||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
|
||||
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
|
||||
{i.atsScore != null && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
|
||||
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
|
||||
</div>
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
|
||||
</div>
|
||||
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{i.email}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{i.phone}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{i.experience} years</div></div>
|
||||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{i.recruiter}</div></div>
|
||||
<div className="info-item"><div className="il">Received</div><div className="iv">{fmtDate(i.received)}</div></div>
|
||||
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
|
||||
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
|
||||
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Match</div>
|
||||
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
|
||||
<div className="il">Received</div>
|
||||
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
||||
</div>
|
||||
{i.atsScore != null && (
|
||||
<div className="info-item">
|
||||
<div className="il">Match</div>
|
||||
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="fw-600"><Icon name="paperclip" /> {i.attachment}</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||||
{i.hasAttachment && (
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="fw-600"><Icon name="paperclip" /> {orDash(i.attachment)}</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||||
</div>
|
||||
{/* The real extracted PDF text (inbox_messages.resume_text), written
|
||||
by the matching task. Empty until that task has run. */}
|
||||
<pre className="resume-thumb">
|
||||
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
|
||||
</pre>
|
||||
</div>
|
||||
<pre className="resume-thumb">{resumeText(i)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button>
|
||||
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
|
||||
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
|
||||
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
|
||||
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
|
||||
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
|
||||
<Icon name="user-plus" /> Import Candidate
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
|
||||
<Icon name="sparkles" /> Parse Resume
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
|
||||
<Icon name="users" /> Assign Recruiter
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
|
||||
<Icon name="layers" /> Move to Pipeline
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onNote} disabled title={noBackend}>
|
||||
<Icon name="edit" /> Add Note
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={onReject}
|
||||
disabled
|
||||
title={noBackend}
|
||||
>
|
||||
<Icon name="x" /> Reject
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue