Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin page for viewing all positions in a recruitment #1615

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,5 +610,6 @@
samfundet__feedback = 'samfundet:feedback'
samfundet__purchase_feedback = 'samfundet:purchase_feedback'
samfundet__gang_application_stats = 'samfundet:gang-application-stats'
samfundet__recruitment_all_applications = 'samfundet:recruitment-all-applications'
static__path = ''
media__path = ''
28 changes: 28 additions & 0 deletions backend/samfundet/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,34 @@ def get_recruitment_position(self, instance: RecruitmentApplication) -> str:
return instance.recruitment_position.name_nb


class RecruitmentApplicationsPerRecruitmentSerializer(serializers.ModelSerializer):
user = RecruitmentBasicUserSerializer(read_only=True)
recruitment_position = RecruitmentRecruitmentPositionSerializer(read_only=True)

class Meta:
model = RecruitmentApplication
fields = [
'id',
'recruitment',
'user',
'applicant_priority',
'recruitment_position',
'recruiter_status',
'recruiter_priority',
]
read_only_fields = [
'id',
'recruitment',
'user',
'applicant_priority',
'recruitment_position',
'recruiter_priority',
]

def get_recruitment_position(self, instance: RecruitmentApplication) -> str:
return instance.recruitment_position.name_nb


class RecruitmentApplicationForGangSerializer(CustomBaseSerializer):
user = ApplicantInfoSerializer(read_only=True)
interview = InterviewSerializer(read_only=False)
Expand Down
1 change: 1 addition & 0 deletions backend/samfundet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,5 @@
path('feedback/', views.UserFeedbackView.as_view(), name='feedback'),
path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'),
path('recruitment/<int:recruitment_id>/gang/<int:gang_id>/stats/', views.GangApplicationCountView.as_view(), name='gang-application-stats'),
path('recruitment/all-applications/', views.RecruitmentApplicationsPerRecruitmentView.as_view(), name='recruitment-all-applications'),
]
15 changes: 15 additions & 0 deletions backend/samfundet/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
RecruitmentApplicationForApplicantSerializer,
RecruitmentApplicationForRecruiterSerializer,
RecruitmentApplicationUpdateForGangSerializer,
RecruitmentApplicationsPerRecruitmentSerializer,
RecruitmentShowUnprocessedApplicationsSerializer,
RecruitmentPositionSharedInterviewGroupSerializer,
)
Expand Down Expand Up @@ -699,6 +700,20 @@ class RecruitmentApplicationView(ModelViewSet):
queryset = RecruitmentApplication.objects.all()


class RecruitmentApplicationsPerRecruitmentView(ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = RecruitmentApplicationsPerRecruitmentSerializer

def get_queryset(self) -> QuerySet[RecruitmentApplication]:
"""Get all applications for a specific recruitment."""
recruitment_id = self.request.query_params.get('recruitment')
recruitment = get_object_or_404(Recruitment, id=recruitment_id)

return RecruitmentApplication.objects.filter(
recruitment=recruitment, # Using the recruitment object directly
).select_related('user', 'recruitment_position')


@method_decorator(ensure_csrf_cookie, 'dispatch')
class RecruitmentPositionsPerRecruitmentView(ListAPIView):
permission_classes = [AllowAny]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.parentTable {
td {
padding: 0.5em 0.15em; // Vertical padding only, no horizontal padding
}
tr {
border: 1px solid black;
}
.childTable {
width: 100%;
display: flex;
flex-direction: column;
margin: 1rem;
border-collapse: collapse;
}

.childTableRow {
width: 100%;
display: flex;
border: none;
}

.childTableData {
display: flex;
width: 100%;
padding: 0.25em 0.5em;
border-bottom: 1px solid rgba(128, 128, 128, 0.219);
}
}

.filerControlContainer {
display: flex;
width: 100%;
justify-content: space-around;
align-items: center;
margin: 1rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { Icon } from '@iconify/react';
import { type ReactNode, useEffect, useState } from 'react';
import { Button, Dropdown, Modal, Table } from '~/Components';
import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { MultiSelect } from '~/Components/MultiSelect';
import type { TableRow } from '~/Components/Table';
import { getAllRecruitmentApplications } from '~/api';
import type { RecruitmentApplicationDto } from '~/dto';
import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout';
import styles from './RecruitmentAllPositionsAdminPage.module.scss';
interface AllApplicationsData {
applicant: string;
setInterviewButton: ReactNode;
datetime: string;
positions: string[];
gangs: string[];
sections: string[];
priorities: string[];
interviewerPriorities: string[];
statuses: ReactNode[];
}

function calculatePositionSimilarity(app1: AllApplicationsData, app2: AllApplicationsData): number {
const commonPositions = app1.positions.filter((pos) => app2.positions.includes(pos));
return commonPositions.length;
}

function sortByPositionSimilarity(applications: AllApplicationsData[]): AllApplicationsData[] {
if (!applications.length) return [];

const result: AllApplicationsData[] = [applications[0]];
const remaining = applications.slice(1);

while (remaining.length > 0) {
const lastApp = result[result.length - 1];
let maxSimilarity = -1;
let mostSimilarIndex = 0;

remaining.forEach((app, index) => {
const similarity = calculatePositionSimilarity(lastApp, app);
if (similarity > maxSimilarity) {
maxSimilarity = similarity;
mostSimilarIndex = index;
}
});

result.push(remaining[mostSimilarIndex]);
remaining.splice(mostSimilarIndex, 1);
}

return result;
}
const statusOptions: DropdownOption<number>[] = [
{ label: 'Nothing', value: 0 },
{ label: 'Called and accepted', value: 1 },
{ label: 'Called and rejected', value: 2 },
{ label: 'Automatic rejection', value: 3 },
];
export function RecruitmentAllPositionsAdminPage() {
// http://localhost:3000/control-panel/recruitment/:recruitmentId/all-positions/
const [recruitmentApplications, setRecruitmentApplications] = useState<AllApplicationsData[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
getAllRecruitmentApplications('37')
.then((response: RecruitmentApplicationDto[]) => {
// Group applications by user
const userApplications = response.reduce(
(acc, app) => {
const userId = app.user.id;
if (!acc[userId]) {
acc[userId] = {
applicant: `${app.user.first_name} ${app.user.last_name}`.trim() || app.user.email,
setInterviewButton: (
// <SetInterviewManuallyModal
// recruitmentId={Number(app.recruitment) || 0}
// isButtonRounded={false}
// application={acc}
// onSetInterview={onInterviewChange}
// />¨
<Button
theme={'samf'}
onClick={() =>
alert(
'ADD ABILITY TO SET INTERVIEWS IN BULK FOR MULTIPLE POSITIONS WITH SetInterviewManuallyModal',
)
}
>
**PLACEHOLDER** SETT INTERVJU FOR ALLE SØKNADER
</Button>
),
datetime: 'Ikke satt',
positions: [],
gangs: [],
sections: [],
priorities: [],
interviewerPriorities: [],
statuses: [],
};
}
acc[userId].positions.push(app.recruitment_position.name_nb);
acc[userId].gangs.push(app.recruitment_position.gang?.toString() || 'N/A');
acc[userId].sections.push('N/A');
acc[userId].priorities.push(`${app.applicant_priority}`);
acc[userId].interviewerPriorities.push(app.recruiter_priority ? `${app.recruiter_priority}` : 'Not set');
acc[userId].statuses.push(<Dropdown value={-1} options={statusOptions} />);

return acc;
},
{} as Record<number, AllApplicationsData>,
);

setRecruitmentApplications(Object.values(userApplications));
})
.catch((error) => {
console.error(error);
});
}, []);

const columns = [
{ content: 'Søker', sortable: false },
{ content: 'Sett intervju', sortable: false },
{ content: 'Tid og sted', sortable: false },
{ content: 'Søknad på stilling', sortable: false },
{ content: 'Gjeng', sortable: false },
{ content: 'Seksjon', sortable: false },
{ content: 'Søkers prioritet', sortable: false },
{ content: 'Intervjuers prioritet', sortable: false },
{ content: 'Sett status', sortable: false },
];

const sortedApplications = sortByPositionSimilarity(recruitmentApplications);

const tableData: TableRow[] = sortedApplications.map((app) => ({
cells: [
{ value: app.applicant, content: app.applicant },
{ value: app.setInterviewButton, content: app.setInterviewButton },
{ value: app.datetime, content: app.datetime },
{
content: (
<table className={styles.childTable}>
{app.positions.map((pos, i) => (
<tr key={i} className={styles.childTableRow}>
<td className={styles.childTableData}>{pos}</td>
</tr>
))}
</table>
),
},
{
content: (
<table className={styles.childTable}>
{app.gangs.map((team, i) => (
<tr key={i} className={styles.childTableRow}>
<td className={styles.childTableData}>{team}</td>
</tr>
))}
</table>
),
},
{
content: (
<table className={styles.childTable}>
{app.sections.map((section, i) => (
<tr key={i} className={styles.childTableRow}>
<td className={styles.childTableData}>{section}</td>
</tr>
))}
</table>
),
},
{
content: (
<table className={styles.childTable}>
{app.priorities.map((priority, i) => (
<tr key={i} className={styles.childTableRow}>
<td className={styles.childTableData}>{priority}</td>
</tr>
))}
</table>
),
},
{
content: (
<table className={styles.childTable}>
{app.interviewerPriorities.map((priority, i) => (
<tr key={i} className={styles.childTableRow}>
<td className={styles.childTableData}>{priority}</td>
</tr>
))}
</table>
),
},
{
content: (
<table className={styles.childTable}>
{app.statuses.map((status, i) => (
<tr key={i} className={styles.childTableRow}>
<td className={styles.childTableData}>{status}</td>
</tr>
))}
</table>
),
},
],
}));

const handleFilterSimilar = () => {
alert('IMPLEMENT FILTER APPLICANTS ON SIMILAR POSITIONS');
};

const handleSetInterviewsForAllApplicants = () => {
alert('IMPLEMENT ABILITY TO AUTOMATICALLY SET INTERVIEWS FOR ALL APPLICANTS');
};

return (
<AdminPageLayout title="All Positions">
<div className={styles.filerControlContainer}>
<Button theme="outlined" onClick={handleFilterSimilar}>
**PLACEHOLDER**Sort by similar positions
</Button>
<Button theme="outlined" onClick={() => setOpen(true)}>
**PLACEHOLDER**Filter by sections
</Button>
<Button theme="outlined" onClick={() => setOpen(true)}>
**PLACEHOLDER**Filter by gangs
</Button>
<Button
theme="samf"
onClick={() => {
handleSetInterviewsForAllApplicants;
}}
>
**PLACEHOLDER** Set interviews for all applicants
</Button>
</div>
<Table className={styles.parentTable} columns={columns} data={tableData} defaultSortColumn={-1} />
<Modal isOpen={open}>
{/* conditionaly fetch sections or gangs, by which button was pressed? */}
<button type="button" className={styles.close_btn} title="Close" onClick={() => setOpen(false)}>
<Icon icon="octicon:x-24" width={24} />
</button>
<MultiSelect />
</Modal>
</AdminPageLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RecruitmentAllPositionsAdminPage } from './RecruitmentAllPositionsAdminPage';
3 changes: 2 additions & 1 deletion frontend/src/PagesAdmin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { InformationFormAdminPage } from './InformationFormAdminPage';
export { InterviewNotesPage } from './InterviewNotesAdminPage';
export { OpeningHoursAdminPage } from './OpeningHoursAdminPage';
export { RecruitmentAdminPage } from './RecruitmentAdminPage';
export { RecruitmentAllPositionsAdminPage } from './RecruitmentAllPositionsAdminPage';
export { RecruitmentApplicantAdminPage } from './RecruitmentApplicantAdminPage';
export { RecruitmentFormAdminPage } from './RecruitmentFormAdminPage';
export { RecruitmentGangAdminPage } from './RecruitmentGangAdminPage';
Expand All @@ -27,9 +28,9 @@ export { RecruitmentSeparatePositionFormAdminPage } from './RecruitmentSeparateP
export { RecruitmentUnprocessedApplicantsPage } from './RecruitmentUnprocessedApplicantsPage';
export { RecruitmentUsersWithoutInterviewGangPage } from './RecruitmentUsersWithoutInterviewGangPage';
export { RecruitmentUsersWithoutThreeInterviewCriteriaPage } from './RecruitmentUsersWithoutThreeInterviewCriteriaPage';
export { RolesAdminPage } from './RolesAdminPage';
export { RoleAdminPage } from './RoleAdminPage';
export { RoleFormAdminPage } from './RoleFormAdminPage';
export { RolesAdminPage } from './RolesAdminPage';
export { CreateInterviewRoomPage, RoomAdminPage } from './RoomAdminPage';
export { SaksdokumentAdminPage } from './SaksdokumentAdminPage';
export { SaksdokumentFormAdminPage } from './SaksdokumentFormAdminPage';
Expand Down
Loading