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

Add position overview - Issue 651 #658

Merged
merged 11 commits into from
Sep 14, 2023
20 changes: 19 additions & 1 deletion backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
DO NOT WRITE IN THIS FILE, AS IT WILL BE OVERWRITTEN ON NEXT UPDATE.

THIS FILE WAS GENERATED BY: root.management.commands.generate_routes
LAST UPDATE: 2023-08-17 17:48:38.961443+00:00
LAST UPDATE: 2023-08-29 08:57:57.685488+00:00
"""

############################################################
Expand Down Expand Up @@ -308,18 +308,36 @@
admin__samfundet_keyvalue_delete = 'admin:samfundet_keyvalue_delete'
admin__samfundet_keyvalue_change = 'admin:samfundet_keyvalue_change'
adminsamfundetkeyvalue__objectId = ''
admin__samfundet_recruitment_permissions = 'admin:samfundet_recruitment_permissions'
admin__samfundet_recruitment_permissions_manage_user = 'admin:samfundet_recruitment_permissions_manage_user'
admin__samfundet_recruitment_permissions_manage_group = 'admin:samfundet_recruitment_permissions_manage_group'
admin__samfundet_recruitment_changelist = 'admin:samfundet_recruitment_changelist'
admin__samfundet_recruitment_add = 'admin:samfundet_recruitment_add'
admin__samfundet_recruitment_history = 'admin:samfundet_recruitment_history'
admin__samfundet_recruitment_delete = 'admin:samfundet_recruitment_delete'
admin__samfundet_recruitment_change = 'admin:samfundet_recruitment_change'
adminsamfundetrecruitment__objectId = ''
admin__samfundet_recruitmentposition_permissions = 'admin:samfundet_recruitmentposition_permissions'
admin__samfundet_recruitmentposition_permissions_manage_user = 'admin:samfundet_recruitmentposition_permissions_manage_user'
admin__samfundet_recruitmentposition_permissions_manage_group = 'admin:samfundet_recruitmentposition_permissions_manage_group'
admin__samfundet_recruitmentposition_changelist = 'admin:samfundet_recruitmentposition_changelist'
admin__samfundet_recruitmentposition_add = 'admin:samfundet_recruitmentposition_add'
admin__samfundet_recruitmentposition_history = 'admin:samfundet_recruitmentposition_history'
admin__samfundet_recruitmentposition_delete = 'admin:samfundet_recruitmentposition_delete'
admin__samfundet_recruitmentposition_change = 'admin:samfundet_recruitmentposition_change'
adminsamfundetrecruitmentposition__objectId = ''
admin__samfundet_recruitmentadmission_permissions = 'admin:samfundet_recruitmentadmission_permissions'
admin__samfundet_recruitmentadmission_permissions_manage_user = 'admin:samfundet_recruitmentadmission_permissions_manage_user'
admin__samfundet_recruitmentadmission_permissions_manage_group = 'admin:samfundet_recruitmentadmission_permissions_manage_group'
admin__samfundet_recruitmentadmission_changelist = 'admin:samfundet_recruitmentadmission_changelist'
admin__samfundet_recruitmentadmission_add = 'admin:samfundet_recruitmentadmission_add'
admin__samfundet_recruitmentadmission_history = 'admin:samfundet_recruitmentadmission_history'
admin__samfundet_recruitmentadmission_delete = 'admin:samfundet_recruitmentadmission_delete'
admin__samfundet_recruitmentadmission_change = 'admin:samfundet_recruitmentadmission_change'
adminsamfundetrecruitmentadmission__objectId = ''
admin__samfundet_organization_permissions = 'admin:samfundet_organization_permissions'
admin__samfundet_organization_permissions_manage_user = 'admin:samfundet_organization_permissions_manage_user'
admin__samfundet_organization_permissions_manage_group = 'admin:samfundet_organization_permissions_manage_group'
admin__samfundet_organization_changelist = 'admin:samfundet_organization_changelist'
admin__samfundet_organization_add = 'admin:samfundet_organization_add'
admin__samfundet_organization_history = 'admin:samfundet_organization_history'
Expand Down
8 changes: 8 additions & 0 deletions backend/samfundet/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,15 @@ class Meta:
]


class ApplicantInfoSerializer(serializers.ModelSerializer):

class Meta:
model = User
fields = ['id', 'first_name', 'last_name', 'email']


class RecruitmentAdmissionForGangSerializer(serializers.ModelSerializer):
user = ApplicantInfoSerializer(read_only=True)

class Meta:
model = RecruitmentAdmission
Expand Down
9 changes: 8 additions & 1 deletion backend/samfundet/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Type

from django.contrib.auth import login, logout
from django.contrib.auth.models import Group
from django.db.models import QuerySet
Expand Down Expand Up @@ -448,6 +447,13 @@ class RecruitmentPositionView(ModelViewSet):
queryset = RecruitmentPosition.objects.all()


@method_decorator(ensure_csrf_cookie, 'dispatch')
class RecruitmentAdmissionView(ModelViewSet):
permission_classes = [AllowAny]
serializer_class = RecruitmentAdmissionForGangSerializer
queryset = RecruitmentAdmission.objects.all()


@method_decorator(ensure_csrf_cookie, 'dispatch')
class RecruitmentPositionsPerRecruitmentView(ListAPIView):
permission_classes = [AllowAny]
Expand Down Expand Up @@ -495,6 +501,7 @@ def list(self, request: Request) -> Response:
class RecruitmentAdmissionForGangView(ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = RecruitmentAdmissionForGangSerializer
queryset = RecruitmentAdmission.objects.all()

# TODO: User should only be able to edit the fields that are allowed

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { RecruitmentFormAdminPage } from './PagesAdmin/RecruitmentFormAdminPage'
import { SaksdokumentAdminPage } from './PagesAdmin/SaksdokumentAdminPage';
import { PERM } from './permissions';
import { ROUTES } from './routes';
import { RecruitmentPositionOverviewPage } from './PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage';

export function AppRoutes() {
// Must be called within <BrowserRouter> because it uses hook useLocation().
Expand Down Expand Up @@ -184,6 +185,10 @@ export function AppRoutes() {
path={ROUTES.frontend.admin_recruitment_gang_position_create}
element={<ProtectedRoute perms={[]} Page={RecruitmentPositionFormAdminPage} />}
/>
<Route
path={ROUTES.frontend.admin_recruitment_gang_position_applicants_overview}
element={<ProtectedRoute perms={[]} Page={RecruitmentPositionOverviewPage} />}
/>
<Route
path={ROUTES.frontend.admin_recruitment_gang_position_edit}
element={<ProtectedRoute perms={[]} Page={RecruitmentPositionFormAdminPage} />}
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/Components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type DropDownOption<T> = {
type DropdownProps<T> = {
className?: string;
defaultValue?: DropDownOption<T>;
initialValue?: T;
options?: DropDownOption<T>[];
label?: string | ReactElement;
disabled?: boolean;
Expand All @@ -22,6 +23,7 @@ type DropdownProps<T> = {
export function Dropdown<T>({
options = [],
defaultValue,
initialValue,
onChange,
className,
label,
Expand Down Expand Up @@ -50,9 +52,9 @@ export function Dropdown<T>({
className={classNames(styles.samf_select, error && styles.error)}
onChange={handleChange}
disabled={disabled}
defaultValue={-1}
defaultValue={initialValue !== undefined ? options.map((e) => e.value).indexOf(initialValue) : -1}
>
{defaultValue ? <option value={-1}>{defaultValue.label}</option> : <option selected value={-1}></option>}
{defaultValue ? <option value={-1}>{defaultValue.label}</option> : <option value={-1}></option>}
{options.map((opt, index) => {
return (
<option value={index} key={index}>
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/Components/InputField/InputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type InputFieldProps<T> = {
labelClassName?: string;
inputClassName?: string;
onChange?: (value: T) => void;
onBlur?: (value: T) => void;
placeholder?: string | null;
type?: InputFieldType;
disabled?: boolean;
Expand All @@ -25,6 +26,7 @@ export function InputField<T>({
labelClassName,
inputClassName,
onChange,
onBlur,
placeholder,
disabled,
value,
Expand All @@ -33,7 +35,7 @@ export function InputField<T>({
type = 'text',
icon,
}: InputFieldProps<T>) {
function handleChange(e?: ChangeEvent<HTMLInputElement>) {
function preprocessValue(e?: ChangeEvent<HTMLInputElement>) {
let value: string | number | undefined = e?.currentTarget.value ?? '';
if (type === 'number') {
if (value.length > 0) {
Expand All @@ -42,13 +44,14 @@ export function InputField<T>({
value = undefined;
}
}
onChange?.(value as T);
return value as T;
}
return (
<label className={classNames(styles.label, disabled && styles.disabled_label, labelClassName)}>
{children}
<input
onChange={handleChange}
onChange={(e) => onChange?.(preprocessValue(e))}
onBlur={(e) => onBlur?.(preprocessValue(e))}
className={classNames(styles.input_field, inputClassName, error && styles.error)}
placeholder={placeholder || ''}
disabled={disabled}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/PagesAdmin/AdminLayout/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react';
import { Icon } from '@iconify/react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -55,10 +56,10 @@ export function AdminLayout() {
{/* Applets */}
{appletCategories.map((category) => {
return (
<>
<React.Fragment key={category.title_en}>
<div className={styles.category_header}>{dbT(category, 'title')}</div>
{category.applets.map((applet, index) => makeAppletShortcut(applet, index))}
</>
</React.Fragment>
);
})}
<br></br>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ export function RecruitmentGangAdminPage() {
const tableColumns = [{ content: t(KEY.recruitment_position), sortable: true }];

const data = recruitmentPositions.map(function (recruitmentPosition) {
const pageUrl = reverse({
pattern: ROUTES.frontend.admin_recruitment_gang_position_applicants_overview,
urlParams: { recruitmentId: recruitmentId, gangId: gangId, positionId: recruitmentPosition.id },
});
return [
{ content: <Link url={ROUTES.frontend.health}>{dbT(recruitmentPosition, 'name')}</Link> },
{
content: <Link url={pageUrl}>{dbT(recruitmentPosition, 'name')}</Link>,
},
{
content: (
<CrudButtons
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RecruitmentAdmissionDto } from '~/dto';
import { getRecruitmentAdmissionsForGang, putRecruitmentAdmissionForGang } from '~/api';
import { KEY } from '~/i18n/constants';
import { Button, Dropdown, InputField, Link } from '~/Components';
import { Table } from '~/Components/Table';
import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout';
import { ROUTES } from '~/routes';
import { reverse } from '~/named-urls';
import { utcTimestampToLocal } from '~/utils';
import { DropDownOption } from '~/Components/Dropdown/Dropdown';

// TODO: Fetch from backend
const priorityOptions: DropDownOption<number>[] = [
{ label: 'Not Set', value: 0 },
{ label: 'Not Wanted', value: 1 },
{ label: 'Wanted', value: 2 },
{ label: 'Reserve', value: 3 },
];

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 },
];

function immutableSet(
list: RecruitmentAdmissionDto[],
oldValue: RecruitmentAdmissionDto,
newValue: RecruitmentAdmissionDto,
) {
return list.map((element: RecruitmentAdmissionDto) => {
if (element.id === oldValue.id) {
return newValue;
} else {
return element;
}
});
}

export function RecruitmentPositionOverviewPage() {
const recruitmentId = useParams().recruitmentId;
const gangId = useParams().gangId;
const positionId = useParams().positionId;
const navigate = useNavigate();
const [recruitmentApplicants, setRecruitmentApplicants] = useState<RecruitmentAdmissionDto[]>([]);
const [showSpinner, setShowSpinner] = useState<boolean>(true);
const { t } = useTranslation();
useEffect(() => {
recruitmentId &&
gangId &&
getRecruitmentAdmissionsForGang(gangId, recruitmentId).then((data) => {
setRecruitmentApplicants(
data.data.filter(
(recruitmentApplicant) => recruitmentApplicant.recruitment_position.toString() == positionId,
),
);
setShowSpinner(false);
});
}, [recruitmentId, gangId, positionId]);

const tableColumns = [
{ content: t(KEY.recruitment_applicant), sortable: true },
{ content: t(KEY.recruitment_priority), sortable: true },
{ content: t(KEY.recruitment_interview_time), sortable: true },
{ content: t(KEY.recruitment_interview_location), sortable: true },
{ content: t(KEY.recruitment_recruiter_priority), sortable: true },
{ content: t(KEY.recruitment_recruiter_status), sortable: true },
];
const data = recruitmentApplicants.map(function (admission) {
return [
{
content: (
<Link
key={admission.user.id}
target={'backend'}
url={reverse({
pattern: ROUTES.backend.admin__samfundet_recruitmentadmission_change,
urlParams: {
objectId: admission.id,
},
})}
>
{`${admission.user.first_name} ${admission.user.last_name}`}
</Link>
),
},
{ content: admission.applicant_priority },
{
content: (
<InputField
value={admission.interview_time ? utcTimestampToLocal(admission.interview_time) : ''}
onBlur={() => putRecruitmentAdmissionForGang(admission.id.toString(), admission)}
onChange={(value: string) => {
const newAdmission = { ...admission, interview_time: value.toString() };
setRecruitmentApplicants(immutableSet(recruitmentApplicants, admission, newAdmission));
}}
type="datetime-local"
/>
),
},
{
content: (
<InputField
value={admission.interview_location ?? ''}
onBlur={() => putRecruitmentAdmissionForGang(admission.id.toString(), admission)}
onChange={(value: string) => {
const newAdmission = { ...admission, interview_location: value.toString() };
setRecruitmentApplicants(immutableSet(recruitmentApplicants, admission, newAdmission));
}}
/>
),
},
{
content: (
<Dropdown
initialValue={admission.recruiter_priority}
options={priorityOptions}
onChange={(value) => {
const newAdmission = { ...admission, recruiter_priority: value };
setRecruitmentApplicants(immutableSet(recruitmentApplicants, admission, newAdmission));
putRecruitmentAdmissionForGang(admission.id.toString(), newAdmission);
}}
/>
),
},
{
content: (
<Dropdown
initialValue={admission.recruiter_status}
options={statusOptions}
onChange={(value) => {
const newAdmission = { ...admission, recruiter_status: value };
setRecruitmentApplicants(immutableSet(recruitmentApplicants, admission, newAdmission));
putRecruitmentAdmissionForGang(admission.id.toString(), newAdmission);
}}
/>
),
},
];
});
const title = t(KEY.admin_information_manage_title);
const backendUrl = reverse({
pattern: ROUTES.backend.admin__samfundet_recruitmentposition_change,
urlParams: {
objectId: positionId,
},
});

const header = (
<Button
theme="success"
rounded={true}
onClick={() =>
navigate(
reverse({
pattern: ROUTES.frontend.admin_recruitment_gang_position_overview,
urlParams: {
gangId: gangId,
recruitmentId: recruitmentId,
},
}),
)
}
>
{t(KEY.common_go_back)}
</Button>
);

return (
<AdminPageLayout title={title} backendUrl={backendUrl} header={header} loading={showSpinner}>
<Table columns={tableColumns} data={data} />
</AdminPageLayout>
);
}
Loading
Loading