Commit c88ebc61 authored by H.M.C. Nadunithara Wijerathne's avatar H.M.C. Nadunithara Wijerathne

Merge branch 'IT19953298' into 'master'

voice recognition

See merge request !3
parents 0458675d 16289d9f
......@@ -11,6 +11,8 @@ import AppState from "./components/AppState";
import "./app.scss";
import "./components.scss";
import ProtectedRoutes from "./components/ProtectedRoute";
import ApplicationsLayout from "./Layouts/ApplicationLayout";
import Applicant from "./views/Application";
function App() {
return (
......@@ -20,7 +22,10 @@ function App() {
<Route element={<ProtectedRoutes />}>
<Route path="/home" element={<Home />} />
<Route path="/settings" element={<Settings />} />
<Route path="/applications" element={<Applications />} />
<Route element={<ApplicationsLayout />}>
<Route path="/applications" element={<Applications />} />
<Route path="/applicant" element={<Applicant />} />
</Route>
</Route>
</Routes>
<AppState />
......
import React, { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { Outlet, useSearchParams } from "react-router-dom";
import { JobPayloadType, Reducers } from "../common/types";
import Layout from "../Layouts/Layout";
const ApplicationsLayout = () => {
const jobs = useSelector(
(state: Reducers) => state.common.jobs
) as JobPayloadType[];
const [searchParams] = useSearchParams();
const [job, setJob] = useState<JobPayloadType | null>();
const jobId = searchParams.get("jobId");
useEffect(() => {
if (jobId) {
const found = jobs.find((_job) => _job._id === jobId);
setJob(found);
}
}, [jobId, jobs]);
const renderSkills = (skill: string, index: number) => (
<span className="skill" key={index}>
{skill}
</span>
);
if (!job) return <Layout title="Applications"></Layout>;
return (
<Layout title={job.title}>
<div className="row">
<div className="col-4">
<div className="card p-4 job-preview">
<p>
Salary : {job.salary.min} - {job.salary.max} {job.salary.currency}{" "}
</p>
<p className="desc">{job.description}</p>
<label>Primary skills</label>
<div className="skills">{job.primarySkills.map(renderSkills)}</div>
<label>Secondary skills</label>
<div className="skills">
{job.secondarySkills.map(renderSkills)}
</div>
<div className="mt-2">
<label>Applicattions : </label>{" "}
<a href={`/applications?jobId=${job._id}`}>
({job.applications?.length}) Candidates
</a>
</div>
</div>
</div>
<div className="col-8">
<Outlet context={job} />
</div>
</div>
</Layout>
);
};
export default ApplicationsLayout;
import React from "react";
import NavBar from "./NavBar";
import NavBar from "../components/NavBar";
type OwnProps = {
title: string;
......
......@@ -17,3 +17,16 @@ export const updateJob = (payload: JobType, success?: () => void) => ({
payload,
success,
});
export const updateApplication = (
payload: {
candidateId: string;
applicationId: string;
update: any;
},
success?: () => void
) => ({
type: ACTIONS.UPDATE_APPLICATION,
payload,
success,
});
......@@ -15,4 +15,5 @@ export enum ACTIONS {
GET_JOBS = "GET_JOBS",
CREATE_JOB = "CREATE_JOB",
UPDATE_JOB = "UPDATE_JOB",
UPDATE_APPLICATION = "UPDATE_APPLICATION",
}
......@@ -13,5 +13,17 @@ export default class CommonAPI {
static applyForJob = (payload: {
application: ApplicationType;
resumeUrl: string;
}) => request("<BASE_URL>/jobs/apply", "PUT", payload);
}) => request("<BASE_URL>/applications/apply", "POST", payload);
static updateApplication = (payload: {
update: any;
applicationId: string;
candidateId: string;
}) => request("<BASE_URL>/applications/update", "PUT", payload);
static analyseInterview = (payload: {
startTime: number;
endTime: number;
applicationId: string;
}) => request("<BASE_URL>/applications/analyse", "POST", payload);
}
import { initializeApp } from "firebase/app";
import { getStorage, ref } from "firebase/storage";
import { getStorage } from "firebase/storage";
export const BASE_URL = "http://localhost:5000";
export const DEFAULT_CONTROLS = {
......
import {
AddressType,
ApplicationType,
CandidateType,
OrganizationType,
......@@ -60,6 +61,20 @@ export const getStatusColor = (status?: ApplicationType["status"]) => {
? "text-bg-warning"
: status === "Rejected"
? "text-bg-danger"
: "text-bg-secondary";
: "text-bg-primary";
return color;
};
export const getAddress = (address: AddressType) => {
return `${address.addressLine}, ${address.city}, ${address.country}`;
};
export const getVerificationColor = (score: number) => {
const color =
score >= 80
? "text-success"
: score >= 60 && score < 80
? "text-warning"
: "text-danger";
return color;
};
......@@ -7,7 +7,7 @@ function* getJobs({
success,
}: {
type: typeof ACTIONS.GET_JOBS;
success: () => void;
success?: () => void;
}) {
try {
const data: { jobs: JobType; success: boolean } = yield call(
......@@ -17,9 +17,9 @@ function* getJobs({
yield put({ type: ACTIONS.SET_JOBS, payload: data.jobs });
}
success();
success?.();
} catch (error) {
success();
success?.();
}
}
......@@ -103,8 +103,46 @@ function* updateJob({
}
}
function* updateApplication({
payload,
success,
}: {
type: typeof ACTIONS.UPDATE_APPLICATION;
payload: { applicationId: string; update: any; candidateId: string };
success?: () => void;
}) {
try {
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.LOADING },
});
const data: { success: boolean } = yield call(
CommonAPI.updateApplication,
payload
);
if (data.success) {
yield call(getJobs, { type: ACTIONS.GET_JOBS });
}
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.SUCCESS },
});
success?.();
} catch (error) {
success?.();
yield put({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.FAILED },
});
}
}
export default function* authSaga() {
yield takeLeading(ACTIONS.GET_JOBS, getJobs);
yield takeLeading(ACTIONS.CREATE_JOB, createJob);
yield takeLeading(ACTIONS.UPDATE_JOB, updateJob);
yield takeLeading(ACTIONS.UPDATE_APPLICATION, updateApplication);
}
......@@ -95,6 +95,7 @@ export type CandidateType = {
profilePicture: string;
state: "INTIAL" | "READY";
resume?: string;
selfIntro?: string;
};
export type OrganizationType = {
......@@ -191,6 +192,7 @@ export type JobPayloadType = {
};
export type ApplicationPayloadType = {
_id: string;
candidate: CandidateType;
job: string;
status: "Pending" | "Accepted" | "In progress" | "Rejected";
......@@ -199,6 +201,7 @@ export type ApplicationPayloadType = {
time: string;
link: string;
videoRef?: string;
voiceVerification?: number;
};
score: {
primary: number;
......
......@@ -207,3 +207,24 @@
margin-top: 5px;
}
}
.matches {
display: flex;
p {
padding: 2px 10px;
background: lightcoral;
margin-right: 10px;
border-radius: 8px;
}
}
.upload-interview-video {
background-color: #f5f5f5;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
input {
display: none;
}
}
import React from "react";
import ReactPlayer from "react-player";
import { getAddress } from "../../common/lib/util";
import { ApplicationPayloadType } from "../../common/types";
import Avatar from "../Avatar";
type OwnProps = {
application: ApplicationPayloadType;
};
const Applicant = ({ application }: OwnProps) => {
const { candidate, score } = application;
const renderMatches = (match: string, index: number) => (
<p key={index}>{match}</p>
);
return (
<>
<div className="mb-3 row">
<div className="col-sm-3 ">
<Avatar
name={candidate?.name}
url={candidate?.profilePicture}
size="lg"
/>
</div>
<div className="col-sm-9">
<div className="mb-3 row">
<label className="col-sm-3">Name</label>
<div className="col-sm-9">
<p>
<strong>{candidate.name}</strong>
</p>
</div>
</div>
<div className="row">
<label className="col-sm-3 col-form-label">Resume</label>
<div className="col-sm-9">
<a
className="btn btn-secondary btn-sm"
href={candidate.resume}
target="_blank"
rel="noreferrer"
>
Preview
</a>
</div>
</div>
</div>
</div>
<div className="mb-3 row">
<div className="col-sm-6">
<div className="row">
<label className="col-sm-4 col-form-label">Address</label>
<div className="col-sm-8">
<textarea
className="form-control"
rows={3}
value={getAddress(candidate.contacts.address)}
disabled
/>
</div>
</div>
</div>
<div className="col-sm-6">
<div className="row">
<label className="col-sm-3 col-form-label">Phone</label>
<div className="col-sm-9">
<input
type="text"
className="form-control"
name="phone"
value={candidate.contacts?.phone}
disabled
/>
</div>
</div>
</div>
</div>
<ReactPlayer
url={candidate.selfIntro}
controls
width="100%"
height="100%"
/>
<hr />
<table className="table table-hover table-sm">
<thead>
<tr>
<th scope="col">Primary Score</th>
<th scope="col">Secondary Score</th>
<th scope="col">Similarity</th>
<th scope="col">Total score</th>
</tr>
</thead>
<tbody>
<tr>
<td>{score.primary}</td>
<td>{score.secondary}</td>
<td>{score.similarity}</td>
<td>{score.total}</td>
</tr>
</tbody>
</table>
<label className="mb-2">
<strong>Primary match</strong>
</label>
<div className="matches">{score.primatyMatch?.map(renderMatches)}</div>
<label className="mt-3">
<strong>Secondary match</strong>
</label>
<div className="matches">{score.secondaryMatch?.map(renderMatches)}</div>
</>
);
};
export default Applicant;
import React, { useState, useRef, ChangeEvent } from "react";
import { getDownloadURL, ref, uploadBytesResumable } from "firebase/storage";
import moment from "moment";
import ReactPlayer from "react-player";
import { OnProgressProps } from "react-player/base";
import CommonAPI from "../../common/apis/common";
import { ApplicationPayloadType } from "../../common/types";
import Progress from "../Progress";
import { fileStorage } from "../../common/config";
import { useDispatch } from "react-redux";
import { updateApplication } from "../../common/actions/common";
import { getVerificationColor } from "../../common/lib/util";
type OwnProps = {
application: ApplicationPayloadType;
};
type Analysis = {
voice: string;
};
const Interview = ({ application }: OwnProps) => {
const dispatch = useDispatch();
const [analysis, setAnalysis] = useState<Analysis | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [startTime, setStartTime] = useState<number>(0);
const [progress, setProgress] = useState<number>(0);
const [endTime, setEndTime] = useState<number>(120);
const _selectedTime = useRef<number>(0);
const _video = useRef<ReactPlayer>(null);
const _hiddenFileInput = useRef<HTMLInputElement>(null);
const onSeek = (e: number) => (_selectedTime.current = e);
const onProgress = (e: OnProgressProps) =>
(_selectedTime.current = e.playedSeconds);
const onSetStartTime = () => setStartTime(_selectedTime.current);
const onSetEndTime = () => setEndTime(_selectedTime.current);
const displayStartTime = `${moment.utc(startTime * 1000).format("HH:mm:ss")}`;
const displayEndTime = `${moment.utc(endTime * 1000).format("HH:mm:ss")}`;
const onClickAnalyse = () => {
if (startTime !== endTime) {
setIsLoading(true);
CommonAPI.analyseInterview({
startTime,
endTime,
applicationId: application._id,
})
.then((res: any) => {
setAnalysis(res);
setIsLoading(false);
})
.catch((err) => {
setIsLoading(false);
});
}
};
const onSelectVideo = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setIsLoading(true);
const path = `/interviews/${application._id}.mp4`;
const uploadRef = ref(fileStorage, path);
const uploadTask = uploadBytesResumable(uploadRef, e.target.files[0]);
uploadTask.on(
"state_changed",
(snapshot) => {
const percent = Math.round(
(snapshot.bytesTransferred / snapshot.totalBytes) * 100
);
setProgress(percent);
},
(err) => {},
() => {
getDownloadURL(uploadTask.snapshot.ref).then((url) => {
dispatch(
updateApplication(
{
applicationId: application._id,
candidateId: application.candidate._id || "",
update: { interview: { videoRef: url } },
},
() => {
setProgress(0);
setIsLoading(false);
}
)
);
});
}
);
}
};
const onClickUpload = () => {
_hiddenFileInput?.current?.click();
};
const renderLoading = isLoading && (
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
);
if (!application.interview?.videoRef) {
return (
<>
<div className="upload-interview-video">
<button
type="button"
className="btn btn-secondary"
onClick={onClickUpload}
disabled={isLoading}
>
Upload video
</button>
<input
type="file"
accept="video/mp4"
ref={_hiddenFileInput}
onChange={onSelectVideo}
/>
</div>
<Progress progress={progress} />
</>
);
}
const renderVoiceVerification = () => {
if (application.interview?.voiceVerification) {
const score = application?.interview?.voiceVerification * 100;
const percentage = `${score?.toFixed(2)}%`;
return (
<strong
className={getVerificationColor(
application?.interview?.voiceVerification * 100
)}
>
Voice verification: {percentage}
</strong>
);
}
return null;
};
return (
<div>
<ReactPlayer
ref={_video}
url={application.interview.videoRef}
controls
height="100%"
width="100%"
onSeek={onSeek}
onProgress={onProgress}
/>
{renderVoiceVerification()}
<div className="row" style={{ padding: "12px" }}>
<div className="col-sm-4">
<div className="row">
<button
type="button"
className="col-sm-4 btn btn-warning btn-sm"
onClick={onSetStartTime}
>
Start time
</button>
<div className="col-sm-8">
<input
type="text"
className="form-control"
name="startTime"
disabled
value={displayStartTime}
/>
</div>
</div>
</div>
<div className="col-sm-4">
<div className="row">
<button
type="button"
className="col-sm-4 btn btn-warning btn-sm"
onClick={onSetEndTime}
>
End time
</button>
<div className="col-sm-8">
<input
type="text"
className="form-control"
name="endTime"
disabled
value={displayEndTime}
/>
</div>
</div>
</div>
<div className="col-sm-4">
<div className="row">
<div className="col-sm-8">
<button
type="button"
className="btn btn-primary w-100"
onClick={onClickAnalyse}
disabled={isLoading}
>
Analyse
</button>
</div>
{renderLoading}
</div>
</div>
</div>
<div className="row mt-2">
<div className="mb-3 row">
<label className="col-sm-2 col-form-label">Voice</label>
<label className="col-sm-5 col-form-label">
<strong>{analysis?.voice}</strong>
</label>
</div>
</div>
</div>
);
};
export default Interview;
......@@ -7,12 +7,15 @@ import { fileStorage } from "../common/config";
import { getProfile } from "../common/lib/util";
import { APP_STATE, CandidateType } from "../common/types";
import Avatar from "./Avatar";
import Progress from "./Progress";
const CandidateProfile = () => {
const dispath = useDispatch();
const candidate = useSelector(getProfile) as CandidateType;
const [profile, setProfile] = useState<CandidateType>(candidate);
const [resume, setResume] = useState<File | null>(null);
const [selfIntro, setSelfIntro] = useState<File | null>(null);
const [progress, setProgress] = useState<number>(0);
useEffect(() => {
if (candidate) {
......@@ -51,33 +54,66 @@ const CandidateProfile = () => {
dispath(updateCandidate(payload));
};
const onSubmit = () => {
const { jobIds, state, _id, ...rest } = profile;
dispath({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.LOADING },
});
if (resume) {
const resumeRef = ref(fileStorage, `/resumes/${profile._id}.pdf`);
const uploadTask = uploadBytesResumable(resumeRef, resume);
const uploadSingleFile = (path: string, file: File) => {
return new Promise((resolve, reject) => {
const uploadRef = ref(fileStorage, path);
const uploadTask = uploadBytesResumable(uploadRef, file);
uploadTask.on(
"state_changed",
(snapshot) => {
const percent = Math.round(
(snapshot.bytesTransferred / snapshot.totalBytes) * 100
);
console.log(percent);
setProgress(percent);
},
(err) => {
console.log("ERROR ", err);
reject(err);
},
() => {
getDownloadURL(uploadTask.snapshot.ref).then((url) => {
console.log(url);
onUpdateCandidate({ ...rest, resume: url });
setProgress(0);
resolve(url);
});
}
);
});
};
const onSubmit = async () => {
const { jobIds, state, _id, ...rest } = profile;
dispath({
type: ACTIONS.SET_APP_STATE,
payload: { state: APP_STATE.LOADING },
});