From f4317fe98ce4638352d8ffec4918d6da221ce111 Mon Sep 17 00:00:00 2001 From: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> Date: Thu, 17 Oct 2024 21:56:32 +0200 Subject: [PATCH 01/53] 1441 loading indicator for recruitment statistics (#1527) * add loader, make page less ugly, but still ugly --- .../SamfundetLogoSpinner.module.scss | 33 +++-- .../SamfundetLogoSpinner.tsx | 9 +- .../RecruitmentStatistics.module.scss | 17 ++- .../RecruitmentStatistics.tsx | 114 ++++++++++++------ frontend/src/api.ts | 4 +- frontend/src/hooks.ts | 27 ++++- 6 files changed, 146 insertions(+), 58 deletions(-) diff --git a/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.module.scss b/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.module.scss index f44f7955f..e1c0397c3 100644 --- a/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.module.scss +++ b/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.module.scss @@ -2,6 +2,30 @@ @import 'src/mixins'; +.center { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +.left { + display: flex; + justify-content: flex-start; + align-items: center; + width: 100%; + height: 100%; +} + +.right { + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; + height: 100%; +} + /* The animation code */ @keyframes spinner-appear-after-delay { 0% { @@ -61,13 +85,13 @@ $time: 1.75s; max-width: 5em; max-height: 5em; opacity: 0; - animation: spinner-appear-after-delay .5s 0.25s forwards; + animation: spinner-appear-after-delay 0.5s 0.25s forwards; @include theme-dark { /* stylelint-disable-next-line function-no-unknown */ color: $black-t90; } - + // These aren't actually global in the context of the module parent class. // This is required to ensure class names are not hashed because the // raw class name string is used in the SamfundetLogo SVG definition @@ -86,9 +110,4 @@ $time: 1.75s; transform-origin: center; animation: inner-anim $time $delay $ease-func infinite normal; } - } - - - - diff --git a/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.tsx b/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.tsx index 001b66e9b..6cb5048a0 100644 --- a/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.tsx +++ b/frontend/src/Components/SamfundetLogoSpinner/SamfundetLogoSpinner.tsx @@ -4,9 +4,14 @@ import styles from './SamfundetLogoSpinner.module.scss'; type SamfundetLogoSpinnerProps = { className?: string; + position?: 'center' | 'left' | 'right'; }; -export function SamfundetLogoSpinner({ className }: SamfundetLogoSpinnerProps) { +export function SamfundetLogoSpinner({ className, position }: SamfundetLogoSpinnerProps) { const classnames = classNames(className, styles.spinning_logo); - return ; + return ( +
+ +
+ ); } diff --git a/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.module.scss b/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.module.scss index c90b08831..f6de5a4ce 100644 --- a/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.module.scss +++ b/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.module.scss @@ -1,18 +1,17 @@ -@import "src/constants"; +@import 'src/constants'; -@import "src/mixins"; +@import 'src/mixins'; -.container{ - margin-top: 2rem; +.container { @include flex-column-center; - gap:2rem; + gap: 2rem; padding-left: 2em; padding-right: 2em; } -.subContainer{ - display: grid; - grid-template-columns: auto auto; +.subContainer { + @include flex-column-center; + width: 100%; + align-items: center; gap: 2rem; } - diff --git a/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.tsx b/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.tsx index 7f74676fa..a70a04f59 100644 --- a/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.tsx +++ b/frontend/src/PagesAdmin/RecruitmentOverviewPage/Components/RecruitmentStatistics/RecruitmentStatistics.tsx @@ -1,32 +1,59 @@ -import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { Chart } from '~/Components'; +import { Chart, SamfundetLogoSpinner } from '~/Components'; import { Table } from '~/Components/Table'; import { Text } from '~/Components/Text/Text'; import { getRecruitmentStats } from '~/api'; import type { RecruitmentStatsDto } from '~/dto'; +import { useCustomNavigate, useParentElementWidth } from '~/hooks'; import { KEY } from '~/i18n/constants'; +import { ROUTES } from '~/routes'; import styles from './RecruitmentStatistics.module.scss'; export function RecruitmentStatistics() { const { recruitmentId } = useParams(); - const [stats, setStats] = useState(); const { t } = useTranslation(); - // TODO: add dynamic data and might need backend features (in ISSUE #1110) + const navigate = useCustomNavigate(); + const chartRef = useRef(null); + const chartContainerWidth = useParentElementWidth(chartRef); + const chartContainerRef = useRef(null); - useEffect(() => { - if (recruitmentId) { - getRecruitmentStats(recruitmentId) - .then((response) => { - setStats(response.data); - }) - .catch(() => { - toast.error(t(KEY.common_something_went_wrong)); - }); + const [height, setHeight] = useState(null); + const [width, setWidth] = useState(null); + const div = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setHeight(node.getBoundingClientRect().height); + setWidth(node.getBoundingClientRect().width); } - }, [recruitmentId, t]); + }, []); + + // TODO: add dynamic data and might need backend features (in ISSUE #1110) + + if (typeof recruitmentId !== 'string') { + navigate({ url: ROUTES.frontend.admin_recruitment }); + toast.error(t(KEY.common_something_went_wrong)); + } + + const { + data: stats, + isLoading, + error, + } = useQuery({ + queryKey: ['recruitmentStats', recruitmentId], + queryFn: () => getRecruitmentStats(recruitmentId as string), + enabled: typeof recruitmentId === 'string', + }); + + if (isLoading) { + return ; + } + + if (error) { + toast.error(t(KEY.common_something_went_wrong)); + } return (
@@ -84,29 +111,42 @@ export function RecruitmentStatistics() { { cells: [`${t(KEY.common_total)} ${t(KEY.recruitment_applications)}`, stats.total_applications] }, ]} /> -
- { - return { value: time.count, label: time.hour.toString() }; - })} - /> - { - return { value: date.count, label: date.date }; - })} - /> +
+
+ { + return { value: time.count, label: time.hour.toString() }; + })} + /> +
+
+ { + return { value: date.count, label: date.date }; + })} + /> +
> { +export async function getRecruitmentStats(id: string): Promise { const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__recruitment_stats_detail, urlParams: { pk: id } }); const response = await axios.get(url, { withCredentials: true }); - return response; + return response.data; } export async function postFeedback(feedbackData: FeedbackDto): Promise { diff --git a/frontend/src/hooks.ts b/frontend/src/hooks.ts index 31179e05b..70512a9c5 100644 --- a/frontend/src/hooks.ts +++ b/frontend/src/hooks.ts @@ -1,4 +1,4 @@ -import { type MutableRefObject, useEffect, useRef, useState } from 'react'; +import { type MutableRefObject, type RefObject, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { getTextItem, putUserPreference } from '~/api'; @@ -478,3 +478,28 @@ export function useDebounce(value: T, delay: number): T { ); return debouncedValue; } + +export function useParentElementWidth(childRef: RefObject) { + const [parentWidth, setParentWidth] = useState(0); + + useEffect(() => { + const handleResize = (entries: ResizeObserverEntry[]) => { + if (entries[0].contentRect.width > 0) { + setParentWidth(entries[0].contentRect.width); + } + }; + + const observer = new ResizeObserver(handleResize); + + if (childRef.current && childRef.current.parentNode instanceof HTMLElement) { + observer.observe(childRef.current.parentNode); + } + + // Clean up + return () => { + observer.disconnect(); + }; + }, [childRef]); + + return parentWidth; +} From 23d67018374a41a9560a833b6b8207ed86803eec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 22:00:40 +0200 Subject: [PATCH 02/53] Bump markdown-to-jsx from 7.3.2 to 7.5.0 in /frontend (#1528) Bumps [markdown-to-jsx](https://github.com/quantizor/markdown-to-jsx) from 7.3.2 to 7.5.0. - [Release notes](https://github.com/quantizor/markdown-to-jsx/releases) - [Changelog](https://github.com/quantizor/markdown-to-jsx/blob/main/CHANGELOG.md) - [Commits](https://github.com/quantizor/markdown-to-jsx/compare/v7.3.2...v7.5.0) --- updated-dependencies: - dependency-name: markdown-to-jsx dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> --- frontend/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index df26401fa..abf1caea4 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -13638,11 +13638,11 @@ __metadata: linkType: hard "markdown-to-jsx@npm:^7.1.8": - version: 7.3.2 - resolution: "markdown-to-jsx@npm:7.3.2" + version: 7.5.0 + resolution: "markdown-to-jsx@npm:7.5.0" peerDependencies: react: ">= 0.14.0" - checksum: 10c0/191b9a9defeed02e12dd340cebf279f577266dac7b34574fa44ce4d64ee8536f9967d455b8303c853f84413feb473118290a6160d8221eeaf3b9e4961b8980e3 + checksum: 10c0/88213e64afd41d6934fbb70bcea0e2ef1f9553db1ba4c6f423b17d6e9c2b99c82b0fcbed29036dd5b91704b170803d1fae730ab40ae27af5c7994e2717686ebc languageName: node linkType: hard From 89d78647ef9608d09addf71968e95712566677be Mon Sep 17 00:00:00 2001 From: amaliejvik <125203980+amaliejvik@users.noreply.github.com> Date: Thu, 17 Oct 2024 22:29:03 +0200 Subject: [PATCH 03/53] 1135 set interview time for applicant through availability modal (#1513) * Beginning of being able to set interview time with new SetInterviewManuallyModal - Move TimeslotButton and TimeslotContainer out of OccupiedForm>components - Create SetInterviwManually "skeleton" - Add some translations - Add SetInterviwManuallyModal to table RecruitmentApplicantsStatus in RecruitmentPositionOverviewPage Related to #1135 * Move TimeslotButton into TimeslotContainer as a subcomponent Related to #1135 * Continuation of refactoring TimeslotContainer and making it more general. Add some props in order to support this. Related to #1135 * Beginning of being able to set interview time with new SetInterviewManuallyModal - Move TimeslotButton and TimeslotContainer out of OccupiedForm>components - Create SetInterviwManually "skeleton" - Add some translations - Add SetInterviwManuallyModal to table RecruitmentApplicantsStatus in RecruitmentPositionOverviewPage Related to #1135 * Add InputField for Location to SetInterviewManually Related to #1135 * Beginning of creating endpoint for saving interview time and location for application Co-authored-by: Lidavic * Create endpoint for setting interview Related to #1135 * Some fixes * Fix that the interview created is actually assigned to the person's RecruitmentApplication Related to #1135 * Make sure new interview-time and location is updated in RecruitmentApplicantsStatus component Related to #1135 * SetInterviewManuallyForm fetches the current interview_time and interview_location if there alrerady exist an interview on the RecruitmentApplication Related to #1135 * SetInterviewManuallyForm fetches the current interview_time and interview_location if there alrerady exist an interview on the RecruitmentApplication * Revert "SetInterviewManuallyForm fetches the current interview_time and interview_location if there alrerady exist an interview on the RecruitmentApplication" This reverts commit c9f7815adc7852d6f770b72515606c13a321090e. * Fix async problems Relatd to #1135 * Enhance translations for SetInterviewManuallyForm and handle text in TimeslotContainer for both use cases (OccupiedTimes & SetInterview) Related to #1135 * Small comment for the "quick fix" in TimeslotContainer * Improve code (remove useState that is not used) Related to #1135 * More intuitive naming Related to #1135 * Fix typescript compiler check Related to #1135 * Remove unused import Related to #1135 * Fix biome Related to #1135 * Fix biome again Related to #1135 * Possible Ruff fix Related to #1135 * Another possible fix for Ruff Related to #1135 * fix: setinterviewmanuallyform works again when interview is not set Related to #1135 * fix: add missing translation Related to #1135 * fix: delete pipfile Related to #1135 * Change order in RecruitmentApplicantStatusTable, interview_time and interview_location are no longer InputFields and remove unused imports Related to #1135 * Fix biome Related to #1135 * Fix tsc check Related to #1135 * 1441 loading indicator for recruitment statistics (#1527) * add loader, make page less ugly, but still ugly * Bump markdown-to-jsx from 7.3.2 to 7.5.0 in /frontend (#1528) Bumps [markdown-to-jsx](https://github.com/quantizor/markdown-to-jsx) from 7.3.2 to 7.5.0. - [Release notes](https://github.com/quantizor/markdown-to-jsx/releases) - [Changelog](https://github.com/quantizor/markdown-to-jsx/blob/main/CHANGELOG.md) - [Commits](https://github.com/quantizor/markdown-to-jsx/compare/v7.3.2...v7.5.0) --- updated-dependencies: - dependency-name: markdown-to-jsx dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> * Fix --------- Signed-off-by: dependabot[bot] Co-authored-by: Lidavic Co-authored-by: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/root/utils/routes.py | 1 - backend/samfundet/models/recruitment.py | 2 + .../Components/MiniCalendar/MiniCalendar.tsx | 14 +- .../Components/OccupiedForm/OccupiedForm.tsx | 25 +-- .../TimeslotContainer/TimeslotContainer.tsx | 135 ------------ .../OccupiedForm/components/index.ts | 2 - .../RecruitmentApplicantsStatus.module.scss | 6 + .../RecruitmentApplicantsStatus.tsx | 44 ++-- .../SetInterviewManually.module.scss | 83 +++++++ .../SetInterviewManuallyForm.tsx | 204 ++++++++++++++++++ .../SetInterviewManuallyModal.tsx | 48 +++++ .../Components/SetInterviewManually/index.ts | 2 + .../Components/TimeDisplay/TimeDisplay.tsx | 15 +- .../TimeslotContainer.module.scss | 0 .../TimeslotContainer/TimeslotContainer.tsx | 184 ++++++++++++++++ .../TimeslotButton/TimeslotButton.module.scss | 25 ++- .../TimeslotButton/TimeslotButton.tsx | 6 +- .../components/TimeslotButton/index.ts | 0 .../TimeslotContainer/index.ts | 0 frontend/src/Components/index.ts | 31 ++- frontend/src/Forms/SamfFormFieldTypes.tsx | 2 +- .../RecruitmentPositionOverviewPage.tsx | 99 +++++---- frontend/src/api.ts | 10 + frontend/src/i18n/constants.ts | 3 + frontend/src/i18n/translations.ts | 6 + frontend/src/routes/backend.ts | 2 +- 26 files changed, 709 insertions(+), 240 deletions(-) delete mode 100644 frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.tsx delete mode 100644 frontend/src/Components/OccupiedForm/components/index.ts create mode 100644 frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss create mode 100644 frontend/src/Components/SetInterviewManually/SetInterviewManuallyForm.tsx create mode 100644 frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx create mode 100644 frontend/src/Components/SetInterviewManually/index.ts rename frontend/src/Components/{OccupiedForm/components => }/TimeslotContainer/TimeslotContainer.module.scss (100%) create mode 100644 frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx rename frontend/src/Components/{OccupiedForm => TimeslotContainer}/components/TimeslotButton/TimeslotButton.module.scss (61%) rename frontend/src/Components/{OccupiedForm => TimeslotContainer}/components/TimeslotButton/TimeslotButton.tsx (68%) rename frontend/src/Components/{OccupiedForm => TimeslotContainer}/components/TimeslotButton/index.ts (100%) rename frontend/src/Components/{OccupiedForm/components => }/TimeslotContainer/index.ts (100%) diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 763e2a80a..cebeabf11 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -553,7 +553,6 @@ samfundet__interview_list = 'samfundet:interview-list' samfundet__interview_detail = 'samfundet:interview-detail' samfundet__api_root = 'samfundet:api-root' -samfundet__api_root = 'samfundet:api-root' samfundet__schema = 'samfundet:schema' samfundet__swagger_ui = 'samfundet:swagger_ui' samfundet__redoc = 'samfundet:redoc' diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index f4b9548b9..2df6249d9 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -272,6 +272,7 @@ def resolve_gang(self, *, return_id: bool = False) -> Gang | int: class RecruitmentApplication(CustomBaseModel): + # UUID so that applicants cannot see recruitment info with their own id number id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) application_text = models.TextField(help_text='Application text') recruitment_position = models.ForeignKey( @@ -283,6 +284,7 @@ class RecruitmentApplication(CustomBaseModel): created_at = models.DateTimeField(null=True, blank=True, auto_now_add=True) + # Foreign Key because UKA and KSG have shared interviews (multiple applications share the same interview) interview = models.ForeignKey( Interview, on_delete=models.SET_NULL, null=True, blank=True, help_text='The interview for the application', related_name='applications' ) diff --git a/frontend/src/Components/MiniCalendar/MiniCalendar.tsx b/frontend/src/Components/MiniCalendar/MiniCalendar.tsx index e124287c3..ab30a15d1 100644 --- a/frontend/src/Components/MiniCalendar/MiniCalendar.tsx +++ b/frontend/src/Components/MiniCalendar/MiniCalendar.tsx @@ -21,12 +21,22 @@ type MiniCalendarProps = { displayLabel?: boolean; /** List of dates to mark with a dot */ markers?: CalendarMarker[]; + /** Selected date can be defined on beforehand */ + initialSelectedDate?: Date | null; }; -export function MiniCalendar({ baseDate, minDate, maxDate, onChange, displayLabel, markers }: MiniCalendarProps) { +export function MiniCalendar({ + baseDate, + minDate, + maxDate, + onChange, + displayLabel, + markers, + initialSelectedDate, +}: MiniCalendarProps) { const [displayDate, setDisplayDate] = useState(baseDate); const [days, setDays] = useState([]); - const [selectedDate, setSelectedDate] = useState(null); + const [selectedDate, setSelectedDate] = useState(initialSelectedDate || null); const { t } = useTranslation(); function getMarker(d: Date | null) { diff --git a/frontend/src/Components/OccupiedForm/OccupiedForm.tsx b/frontend/src/Components/OccupiedForm/OccupiedForm.tsx index 2e7c131e3..449ad162d 100644 --- a/frontend/src/Components/OccupiedForm/OccupiedForm.tsx +++ b/frontend/src/Components/OccupiedForm/OccupiedForm.tsx @@ -1,8 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { MiniCalendar } from '~/Components'; -import { TimeslotContainer } from '~/Components/OccupiedForm/components'; +import { MiniCalendar, TimeslotContainer } from '~/Components'; import { getOccupiedTimeslots, getRecruitmentAvailability, postOccupiedTimeslots } from '~/api'; import type { OccupiedTimeslotDto } from '~/dto'; import { KEY } from '~/i18n/constants'; @@ -24,7 +23,7 @@ export function OccupiedForm({ recruitmentId = 1, onCancel }: Props) { const [maxDate, setMaxDate] = useState(new Date('2024-01-24')); const [timeslots, setTimeslots] = useState([]); - const [selectedTimeslots, setSelectedTimeslots] = useState>({}); + const [occupiedTimeslots, setOccupiedTimeslots] = useState>({}); // biome-ignore lint/correctness/useExhaustiveDependencies: t does not need to be in deplist useEffect(() => { @@ -44,7 +43,7 @@ export function OccupiedForm({ recruitmentId = 1, onCancel }: Props) { setTimeslots(response.data.timeslots); }), getOccupiedTimeslots(recruitmentId).then((res) => { - setSelectedTimeslots(res.data.dates); + setOccupiedTimeslots(res.data.dates); }), ]) .catch((error) => { @@ -57,7 +56,7 @@ export function OccupiedForm({ recruitmentId = 1, onCancel }: Props) { function save() { const data: OccupiedTimeslotDto = { recruitment: recruitmentId, - dates: selectedTimeslots, + dates: occupiedTimeslots, }; postOccupiedTimeslots(data) @@ -73,14 +72,14 @@ export function OccupiedForm({ recruitmentId = 1, onCancel }: Props) { const markers = useMemo(() => { const x: CalendarMarker[] = []; - for (const d in selectedTimeslots) { - if (selectedTimeslots[d]) { - if (selectedTimeslots[d].length === timeslots.length) { + for (const d in occupiedTimeslots) { + if (occupiedTimeslots[d]) { + if (occupiedTimeslots[d].length === timeslots.length) { x.push({ date: new Date(d), className: styles.fully_busy, }); - } else if (selectedTimeslots[d].length > 0) { + } else if (occupiedTimeslots[d].length > 0) { x.push({ date: new Date(d), className: styles.partly_busy, @@ -89,7 +88,7 @@ export function OccupiedForm({ recruitmentId = 1, onCancel }: Props) { } } return x; - }, [timeslots, selectedTimeslots]); + }, [timeslots, occupiedTimeslots]); return (
@@ -115,8 +114,10 @@ export function OccupiedForm({ recruitmentId = 1, onCancel }: Props) { setSelectedTimeslots(slots)} - selectedTimeslots={selectedTimeslots} + onChange={(slots) => setOccupiedTimeslots(slots)} + activeTimeslots={occupiedTimeslots} + selectMultiple={true} + hasDisabledTimeslots={false} />
diff --git a/frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.tsx b/frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.tsx deleted file mode 100644 index 4090bc7e2..000000000 --- a/frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { TimeslotButton } from '~/Components/OccupiedForm/components'; -import { useMouseDown } from '~/hooks'; -import { KEY } from '~/i18n/constants'; -import { formatDateYMD, lowerCapitalize } from '~/utils'; -import styles from './TimeslotContainer.module.scss'; - -type Props = { - selectedDate: Date | null; - timeslots: string[]; - onChange?: (timeslots: Record) => void; - selectedTimeslots?: Record; -}; - -export function TimeslotContainer({ selectedDate, timeslots, onChange, ...props }: Props) { - const { t } = useTranslation(); - - const [selectedTimeslots, setSelectedTimeslots] = useState>(props.selectedTimeslots || {}); - - // Click & drag functionality - const mouseDown = useMouseDown(); - // dragSetSelected decides whether we select or unselect buttons we drag over - const [dragSetSelected, setDragSetSelected] = useState(false); - - useEffect(() => { - onChange?.(selectedTimeslots); - }, [onChange, selectedTimeslots]); - - function toggleTimeslot(date: Date, timeslot: string) { - const dayString = formatDateYMD(date); - const copy = { ...selectedTimeslots }; - if (selectedTimeslots[dayString]) { - if (copy[dayString].includes(timeslot)) { - copy[dayString] = copy[dayString].filter((s) => s !== timeslot); - if (copy[dayString].length === 0) { - delete copy[dayString]; - } - } else { - copy[dayString].push(timeslot); - } - } else { - copy[dayString] = [timeslot]; - } - setSelectedTimeslots(copy); - } - - function selectTimeslot(date: Date, timeslot: string) { - if (isTimeslotSelected(date, timeslot)) return; - const dayString = formatDateYMD(date); - const copy = { ...selectedTimeslots }; - if (copy[dayString]) { - copy[dayString].push(timeslot); - } else { - copy[dayString] = [timeslot]; - } - setSelectedTimeslots(copy); - } - - function unselectTimeslot(date: Date, timeslot: string) { - if (!isTimeslotSelected(date, timeslot)) return; - const dayString = formatDateYMD(date); - const copy = { ...selectedTimeslots }; - copy[dayString] = copy[dayString].filter((s) => s !== timeslot); - if (copy[dayString].length === 0) { - delete copy[dayString]; - } - setSelectedTimeslots(copy); - } - - function isTimeslotSelected(date: Date, timeslot: string) { - const x = selectedTimeslots[formatDateYMD(date)]; - return !(!x || !x.find((s) => s === timeslot)); - } - - function isAllSelected(date: Date) { - const selectedLength = selectedTimeslots[formatDateYMD(date)]?.length || 0; - return selectedLength === timeslots.length; - } - - function toggleSelectAll(date: Date) { - const slots = { ...selectedTimeslots }; - if (isAllSelected(date)) { - delete slots[formatDateYMD(date)]; - } else { - slots[formatDateYMD(date)] = timeslots; - } - setSelectedTimeslots(slots); - } - - function onMouseEnter(date: Date, timeslot: string) { - if (!mouseDown) return; - if (dragSetSelected) { - selectTimeslot(date, timeslot); - } else { - unselectTimeslot(date, timeslot); - } - } - - if (!selectedDate) { - return
{lowerCapitalize(`${t(KEY.common_choose)} ${t(KEY.common_date)}`)}
; - } - - return ( -
- {t(KEY.occupied_select_time_text)}: -
- {timeslots.map((timeslot) => { - const active = isTimeslotSelected(selectedDate, timeslot); - - return ( - { - toggleTimeslot(selectedDate, timeslot); - setDragSetSelected(!active); - }} - onMouseEnter={() => onMouseEnter(selectedDate, timeslot)} - > - {timeslot} - - ); - })} -
- toggleSelectAll(selectedDate)} - showDot={false} - > - {isAllSelected(selectedDate) ? t(KEY.common_unselect_all) : t(KEY.common_select_all)} - -
- ); -} diff --git a/frontend/src/Components/OccupiedForm/components/index.ts b/frontend/src/Components/OccupiedForm/components/index.ts deleted file mode 100644 index 3a0c45dc1..000000000 --- a/frontend/src/Components/OccupiedForm/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { TimeslotButton } from './TimeslotButton'; -export { TimeslotContainer } from './TimeslotContainer'; diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss index a3f7beac6..b5e9957fa 100644 --- a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss +++ b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss @@ -20,6 +20,12 @@ padding: 0; } +.interviewField { + display: flex; + justify-content: center; + background-color: $pending; +} + .header { font-size: 0.7em; background-color: $grey-3; diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx index c8fae9e9b..ba8665169 100644 --- a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx +++ b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx @@ -1,16 +1,17 @@ import { useTranslation } from 'react-i18next'; -import { InputField } from '~/Components'; +import { InputField, TimeDisplay } from '~/Components'; import { CrudButtons } from '~/Components/CrudButtons/CrudButtons'; import { type DropDownOption, Dropdown } from '~/Components/Dropdown/Dropdown'; import { Table } from '~/Components/Table'; +import { Text } from '~/Components/Text/Text'; import { putRecruitmentApplicationForGang } from '~/api'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; import { useCustomNavigate } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; -import { utcTimestampToLocal } from '~/utils'; import { Link } from '../Link'; +import { SetInterviewManuallyModal } from '../SetInterviewManually'; import styles from './RecruitmentApplicantsStatus.module.scss'; type RecruitmentApplicantsStatusProps = { @@ -19,6 +20,7 @@ type RecruitmentApplicantsStatusProps = { gangId: number | string | undefined; positionId: number | string | undefined; updateStateFunction: (id: string, data: RecruitmentApplicationStateDto) => void; + onInterviewChange: () => void; }; // TODO add backend to fetch these @@ -49,6 +51,7 @@ export function RecruitmentApplicantsStatus({ gangId, positionId, updateStateFunction, + onInterviewChange, }: RecruitmentApplicantsStatusProps) { const { t } = useTranslation(); const navigate = useCustomNavigate(); @@ -56,6 +59,7 @@ export function RecruitmentApplicantsStatus({ const tableColumns = [ { content: t(KEY.recruitment_applicant), sortable: true, hideSortButton: true }, { content: t(KEY.recruitment_priority), sortable: true, hideSortButton: true }, + { content: t(KEY.recruitment_interview_set), sortable: false, hideSortButton: true }, { content: t(KEY.recruitment_interview_time), sortable: true, hideSortButton: true }, { content: t(KEY.recruitment_interview_location), sortable: true, hideSortButton: true }, { content: t(KEY.recruitment_recruiter_priority), sortable: true, hideSortButton: true }, @@ -125,30 +129,34 @@ export function RecruitmentApplicantsStatus({ ), }, { - value: application.interview?.interview_time, - style: applicationStatusStyle, + style: styles.interviewField, content: ( - putRecruitmentApplicationForGang(application.id.toString(), application)} - onChange={(value: string) => updateApplications(application.id, editChoices.update_time, value)} - type="datetime-local" + ), }, + { + value: application.interview?.interview_time, + style: applicationStatusStyle, + content: application.interview?.interview_time ? ( + + ) : ( + {t(KEY.common_not_set)} + ), + }, { value: application.interview?.interview_location, style: applicationStatusStyle, content: ( - putRecruitmentApplicationForGang(application.id, application)} - onChange={(value: string) => updateApplications(application.id, editChoices.update_location, value)} - /> + + {application.interview?.interview_location + ? application.interview?.interview_location + : t(KEY.common_not_set)} + ), }, { diff --git a/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss b/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss new file mode 100644 index 000000000..dbcbdb6ae --- /dev/null +++ b/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss @@ -0,0 +1,83 @@ +@import 'src/constants'; + +@import 'src/mixins'; + +.container { + display: flex; + flex-direction: column; + gap: 0.2em; + padding: 0.5em; +} + +.date_container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 3rem; + + @include for-mobile-down { + grid-template-columns: 1fr; + justify-items: center; + } +} + +.occupied_modal { + min-width: auto; +} + +.partly_busy { + background: $sulten-orange; +} + +.fully_busy { + background: $red; +} + +.add { + margin-left: 1em; + margin-right: 1em; +} + +.title { + font-size: 1.6rem; + font-weight: 700; +} + +.subtitle { + margin: 1rem 0 2rem; +} + +.button_row { + display: flex; + flex-direction: row; + gap: 12rem; + justify-content: center; + align-items: center; + margin-top: 2rem; +} + +.close_btn { + position: absolute; + top: 1.2rem; + right: 1.2rem; + background: none; + padding: 5px; + border: 0; + cursor: pointer; + color: $grey-1; + + &:hover { + color: $black; + } +} + +.input_field { + font-size: 0.9em; + border-radius: 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + margin-bottom: 1em; +} + +.choose_location_text { + margin: 1rem 0 1rem; +} diff --git a/frontend/src/Components/SetInterviewManually/SetInterviewManuallyForm.tsx b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyForm.tsx new file mode 100644 index 000000000..23e6c2376 --- /dev/null +++ b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyForm.tsx @@ -0,0 +1,204 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { InputField, MiniCalendar, TimeslotContainer } from '~/Components'; +import { + getInterview, + getOccupiedTimeslots, + getRecruitmentAvailability, + setRecruitmentApplicationInterview, +} from '~/api'; +import type { InterviewDto, RecruitmentApplicationDto } from '~/dto'; +import { KEY } from '~/i18n/constants'; +import type { CalendarMarker } from '~/types'; +import { formatDateYMD } from '~/utils'; +import { Button } from '../Button'; +import styles from './SetInterviewManually.module.scss'; + +type SetInterviewManuallyFormProps = { + recruitmentId: number; + onCancel?: () => void; + application: RecruitmentApplicationDto; + onSave: () => void; +}; + +export function SetInterviewManuallyForm({ + recruitmentId = 1, + onCancel, + application, + onSave, +}: SetInterviewManuallyFormProps) { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(true); + const [dataLoaded, setDataLoaded] = useState(false); + const [selectedDate, setSelectedDate] = useState(null); + const [minDate, setMinDate] = useState(new Date('2024-01-16')); + const [maxDate, setMaxDate] = useState(new Date('2024-01-24')); + + const [timeslots, setTimeslots] = useState([]); + const [occupiedTimeslots, setOccupiedTimeslots] = useState>({}); //Opptatt timeslots + const [interviewTimeslot, setInterviewTimeslot] = useState>({}); //Intervju timeslot + const [location, setLocation] = useState(''); + + useEffect(() => { + if (!recruitmentId) { + return; + } + setLoading(true); + Promise.allSettled([ + getRecruitmentAvailability(recruitmentId).then((response) => { + if (!response.data) { + toast.error(t(KEY.common_something_went_wrong)); + return; + } + setMinDate(new Date(response.data.start_date)); + setMaxDate(new Date(response.data.end_date)); + setTimeslots(response.data.timeslots); + }), + getOccupiedTimeslots(recruitmentId).then((res) => { + setOccupiedTimeslots(res.data.dates); + }), + ]) + .catch((error) => { + toast.error(t(KEY.common_something_went_wrong)); + console.error(error); + }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recruitmentId, t]); + + useEffect(() => { + if (!application.id || !application.interview?.id) { + setDataLoaded(true); + return; + } + setLoading(true); + + getInterview(application.interview?.id) + .then((response) => { + if (!response.data) { + toast.error(t(KEY.common_something_went_wrong)); + return; + } + const interviewDate = new Date(response.data.interview_time?.split('T')[0]); + const interviewTime = response.data.interview_time?.split('T')[1]?.slice(0, 5); + + if (interviewDate && interviewTime) { + setSelectedDate(interviewDate); + setInterviewTimeslot({ [formatDateYMD(interviewDate)]: [interviewTime] }); + } + setLocation(response.data.interview_location); + }) + .catch(() => { + toast.error(t(KEY.common_something_went_wrong)); + }) + .finally(() => { + setDataLoaded(true); + setLoading(false); + }); + }, [application.id, application.interview?.id, t]); + + function convertToDateObject(dateTimeDict: Record): Date { + const dateKey = Object.keys(dateTimeDict)[0]; + const timeValue = dateTimeDict[dateKey][0]; + + // Split the date and time strings + const [year, month, day] = dateKey.split('.').map(Number); + const [hours, minutes] = timeValue.split(':').map(Number); + + // Create and return the Date object + return new Date(year, month - 1, day, hours, minutes); + } + + function save() { + const data: InterviewDto = { + interview_time: convertToDateObject(interviewTimeslot).toISOString(), + interview_location: location, + }; + + setRecruitmentApplicationInterview(application.id, data) + .then(() => { + onSave(); + toast.success(t(KEY.common_update_successful)); + }) + .catch((error) => { + toast.error(t(KEY.common_something_went_wrong)); + console.error(error); + }); + } + + const markers = useMemo(() => { + const x: CalendarMarker[] = []; + + for (const d in occupiedTimeslots) { + if (occupiedTimeslots[d]) { + if (occupiedTimeslots[d].length === timeslots.length) { + x.push({ + date: new Date(d), + className: styles.fully_busy, + }); + } else if (occupiedTimeslots[d].length > 0) { + x.push({ + date: new Date(d), + className: styles.partly_busy, + }); + } + } + } + return x; + }, [timeslots, occupiedTimeslots]); + + return ( +
+

{t(KEY.recruitment_interview_set)}

+ + {loading || !dataLoaded ? ( + {t(KEY.common_loading)}... + ) : ( + <> + + + +
+ setSelectedDate(date)} + displayLabel={true} + markers={markers} + initialSelectedDate={selectedDate} + /> + + setInterviewTimeslot(slots)} + selectedTimeslot={interviewTimeslot} + disabledTimeslots={occupiedTimeslots} + hasDisabledTimeslots={true} + selectMultiple={false} + /> +
+ {`${t(KEY.recruitment_choose_interview_location)}:`} + setLocation(value as string)} + /> + +
+ + +
+ + )} +
+ ); +} diff --git a/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx new file mode 100644 index 000000000..bc5142eb6 --- /dev/null +++ b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx @@ -0,0 +1,48 @@ +import { Icon } from '@iconify/react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { RecruitmentApplicationDto } from '~/dto'; +import { KEY } from '~/i18n/constants'; +import { Button } from '../Button'; +import { Modal } from '../Modal'; +import styles from './SetInterviewManually.module.scss'; +import { SetInterviewManuallyForm } from './SetInterviewManuallyForm'; + +type SetInterviewManuallyModalProps = { + recruitmentId: number; + isButtonRounded?: boolean; + application: RecruitmentApplicationDto; + onSetInterview: () => void; +}; + +export function SetInterviewManuallyModal({ + recruitmentId = 1, + isButtonRounded = false, + application, + onSetInterview, +}: SetInterviewManuallyModalProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + <> + + + + <> + + setOpen(false)} + application={application} + onSave={onSetInterview} + /> + + + + ); +} diff --git a/frontend/src/Components/SetInterviewManually/index.ts b/frontend/src/Components/SetInterviewManually/index.ts new file mode 100644 index 000000000..43f907bf9 --- /dev/null +++ b/frontend/src/Components/SetInterviewManually/index.ts @@ -0,0 +1,2 @@ +export { SetInterviewManuallyForm } from './SetInterviewManuallyForm'; +export { SetInterviewManuallyModal } from './SetInterviewManuallyModal'; diff --git a/frontend/src/Components/TimeDisplay/TimeDisplay.tsx b/frontend/src/Components/TimeDisplay/TimeDisplay.tsx index f9fcf91f5..b100bcfc3 100644 --- a/frontend/src/Components/TimeDisplay/TimeDisplay.tsx +++ b/frontend/src/Components/TimeDisplay/TimeDisplay.tsx @@ -2,7 +2,15 @@ import { format, isToday, isTomorrow } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { KEY } from '~/i18n/constants'; -type TimeDisplayType = 'datetime' | 'date' | 'nice-date' | 'time' | 'event-date' | 'event-datetime' | 'nice-month-year'; +type TimeDisplayType = + | 'datetime' + | 'date' + | 'nice-date' + | 'time' + | 'event-date' + | 'event-datetime' + | 'nice-month-year' + | 'nice-date-time'; type TimeDisplayProps = { timestamp: string | Date; @@ -71,6 +79,11 @@ export function TimeDisplay({ timestamp, className, displayType = 'datetime' }: return getEventString(); case 'nice-month-year': return `${niceMonths[date.getMonth()]} ${date.getFullYear()}`; + case 'nice-date-time': { + const dateString = date.toISOString(); + const splitTime = dateString.split('T'); + return `${date.toTimeString().slice(0, 5)} || ${niceDays[date.getDay()]} ${date.getDate()}. ${niceMonths[date.getMonth()]}`; + } } } diff --git a/frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.module.scss b/frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss similarity index 100% rename from frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.module.scss rename to frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss diff --git a/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx b/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx new file mode 100644 index 000000000..388531b2b --- /dev/null +++ b/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMouseDown } from '~/hooks'; +import { KEY } from '~/i18n/constants'; +import { formatDateYMD, lowerCapitalize } from '~/utils'; +import styles from './TimeslotContainer.module.scss'; +import { TimeslotButton } from './components/TimeslotButton'; + +type Props = { + selectedDate: Date | null; + timeslots: string[]; + onChange?: (timeslots: Record) => void; + activeTimeslots?: Record; //De røde timeslotsene ( "occupied" ) + selectedTimeslot?: Record; //Den ene grønne timesloten ( "selected" ) + disabledTimeslots?: Record; //De grå timeslotsene ( "disabled" ) + selectMultiple: boolean; + hasDisabledTimeslots: boolean; +}; + +export function TimeslotContainer({ + selectedDate, + timeslots, + onChange, + selectMultiple, + hasDisabledTimeslots, + ...props +}: Props) { + const { t } = useTranslation(); + + const [activeTimeslots, setActiveTimeslots] = useState>(props.activeTimeslots || {}); + const [selectedTimeslot, setSelectedTimeslot] = useState>(props.selectedTimeslot || {}); + const disabledTimeslots = props.disabledTimeslots || {}; + + // Click & drag functionality + const mouseDown = useMouseDown(); + // dragSetSelected decides whether we select or unselect buttons we drag over + const [dragSetSelected, setDragSetSelected] = useState(false); + + useEffect(() => { + if (!selectMultiple) { + if (selectedDate && selectedTimeslot[formatDateYMD(selectedDate)]) { + onChange?.(selectedTimeslot); + } + } else { + onChange?.(activeTimeslots); + } + }, [onChange, activeTimeslots, selectedTimeslot, selectedDate, selectMultiple]); + + function toggleTimeslot(date: Date, timeslot: string) { + if (hasDisabledTimeslots && disabledTimeslots) { + if (disabledTimeslots[formatDateYMD(date)]?.includes(timeslot)) return; + } + if (!selectMultiple) { + if (isTimeslotSelected(date, timeslot)) { + setSelectedTimeslot({ [formatDateYMD(date)]: [] }); + } else { + setSelectedTimeslot({ [formatDateYMD(date)]: [timeslot] }); + } + } else { + const dayString = formatDateYMD(date); + const copy = { ...activeTimeslots }; + if (activeTimeslots[dayString]) { + if (copy[dayString].includes(timeslot)) { + copy[dayString] = copy[dayString].filter((s) => s !== timeslot); + if (copy[dayString].length === 0) { + delete copy[dayString]; + } + } else { + copy[dayString].push(timeslot); + } + } else { + copy[dayString] = [timeslot]; + } + setActiveTimeslots(copy); + } + } + + function selectTimeslot(date: Date, timeslot: string) { + if (isTimeslotSelected(date, timeslot)) return; + const dayString = formatDateYMD(date); + const copy = { ...activeTimeslots }; + if (copy[dayString]) { + copy[dayString].push(timeslot); + } else { + copy[dayString] = [timeslot]; + } + setActiveTimeslots(copy); + } + + function unselectTimeslot(date: Date, timeslot: string) { + if (!isTimeslotSelected(date, timeslot)) return; + const dayString = formatDateYMD(date); + const copy = { ...activeTimeslots }; + copy[dayString] = copy[dayString].filter((s) => s !== timeslot); + if (copy[dayString].length === 0) { + delete copy[dayString]; + } + setActiveTimeslots(copy); + } + + function isTimeslotSelected(date: Date, timeslot: string) { + const x = activeTimeslots[formatDateYMD(date)]; + return x ? x.includes(timeslot) : false; + } + + function isTimeslotDisabled(date: Date, timeslot: string) { + if (!disabledTimeslots) return; + const x = disabledTimeslots[formatDateYMD(date)]; + return x ? x.includes(timeslot) : false; + } + + function isOnlyTimeSlot(date: Date, timeslot: string) { + if (!selectedTimeslot || selectMultiple) return; + return selectedTimeslot[formatDateYMD(date)]?.includes(timeslot); + } + + function isAllSelected(date: Date) { + const selectedLength = activeTimeslots[formatDateYMD(date)]?.length || 0; + return selectedLength === timeslots.length; + } + + function toggleSelectAll(date: Date) { + const slots = { ...activeTimeslots }; + if (isAllSelected(date)) { + delete slots[formatDateYMD(date)]; + } else { + slots[formatDateYMD(date)] = timeslots; + } + setActiveTimeslots(slots); + } + + function onMouseEnter(date: Date, timeslot: string) { + if (!mouseDown || !selectMultiple) return; + if (dragSetSelected) { + selectTimeslot(date, timeslot); + } else { + unselectTimeslot(date, timeslot); + } + } + + if (!selectedDate) { + return
{lowerCapitalize(`${t(KEY.common_choose)} ${t(KEY.common_date)}`)}
; + } + + return ( +
+ {selectMultiple ? `${t(KEY.occupied_select_time_text)}:` : `${t(KEY.recruitment_choose_interview_time)}:`} + {/* ^not a great solution, but works for the current purposes of this TimeslotContainer*/} +
+ {timeslots.map((timeslot) => { + const active = isTimeslotSelected(selectedDate, timeslot); + const disabled = isTimeslotDisabled(selectedDate, timeslot); + const onlyOneChosen = isOnlyTimeSlot(selectedDate, timeslot); + + return ( + { + toggleTimeslot(selectedDate, timeslot); + setDragSetSelected(!active); + }} + onMouseEnter={() => onMouseEnter(selectedDate, timeslot)} + onlyOneValid={onlyOneChosen} + > + {timeslot} + + ); + })} +
+ {selectMultiple && ( + toggleSelectAll(selectedDate)} + showDot={false} + > + {isAllSelected(selectedDate) ? t(KEY.common_unselect_all) : t(KEY.common_select_all)} + + )} +
+ ); +} diff --git a/frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.module.scss b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss similarity index 61% rename from frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.module.scss rename to frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss index 96ed7f8a1..49fc7ce95 100644 --- a/frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.module.scss +++ b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss @@ -5,8 +5,8 @@ .timeslot { padding: 0.5rem 1rem; border-radius: 5px; - border: 1px solid $grey-3; - color: $grey-3; + border: 1px solid $grey-0; + color: $grey-0; cursor: pointer; background: none; position: relative; @@ -23,7 +23,7 @@ height: $size; border-radius: $size; display: inline-block; - background: $grey-3; + background: $grey-0; } .timeslot_active { @@ -35,3 +35,22 @@ } } +.timeslot_disabled { + border-color: $grey-3; + color: $grey-3; + cursor: not-allowed; + + .dot { + background: $grey-3; + } +} + +.timeslot_only_one_valid { + border-color: $green; + color: $green; + cursor: pointer; + + .dot { + background: $green; + } +} diff --git a/frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.tsx b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.tsx similarity index 68% rename from frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.tsx rename to frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.tsx index 6d402c37b..1581f6fb1 100644 --- a/frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.tsx +++ b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.tsx @@ -5,15 +5,19 @@ import styles from './TimeslotButton.module.scss'; interface Props extends React.ButtonHTMLAttributes { active: boolean; + disabled: boolean; + onlyOneValid?: boolean; children?: ReactNode; showDot?: boolean; } -export function TimeslotButton({ active, children, showDot = true, ...props }: Props) { +export function TimeslotButton({ active, disabled, onlyOneValid, children, showDot = true, ...props }: Props) { return ( - ); - // Return profile button for navbar if logged in. const mobileProfileButton = (
@@ -252,7 +238,7 @@ export function Navbar() { {navbarHeaders}
- {languageButton} +
{loginButton} {logoutButton} @@ -275,7 +261,7 @@ export function Navbar() { {isDesktop && navbarHeaders}
- {languageButton} + {loginButton} {profileButton}
diff --git a/frontend/src/Components/Navbar/components/LanguageButton/LanguageButton.module.scss b/frontend/src/Components/Navbar/components/LanguageButton/LanguageButton.module.scss new file mode 100644 index 000000000..3fa14fc03 --- /dev/null +++ b/frontend/src/Components/Navbar/components/LanguageButton/LanguageButton.module.scss @@ -0,0 +1,18 @@ +.language_flag { + width: 28px; + height: 20px; + cursor: pointer; + border-radius: 3px; + box-shadow: 1px 1px 10px 2px rgba(0, 0, 0, 0.1); + transition: 0.1s; + + &:hover { + transform: scale(1.1); + } +} + +.language_flag_button { + background-color: transparent; + border-color: transparent; +} + diff --git a/frontend/src/Components/Navbar/components/LanguageButton/LanguageButton.tsx b/frontend/src/Components/Navbar/components/LanguageButton/LanguageButton.tsx new file mode 100644 index 000000000..894d9bf18 --- /dev/null +++ b/frontend/src/Components/Navbar/components/LanguageButton/LanguageButton.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import { englishFlag, norwegianFlag } from '~/assets'; +import { LOCALSTORAGE_KEY } from '~/i18n/i18n'; +import { LANGUAGES } from '~/i18n/types'; +import styles from './LanguageButton.module.scss'; + +export function LanguageButton() { + const { i18n } = useTranslation(); + + // Language + const currentLanguage = i18n.language; + const isNorwegian = currentLanguage === LANGUAGES.NB; + const otherLanguage = isNorwegian ? LANGUAGES.EN : LANGUAGES.NB; + const otherFlag = isNorwegian ? englishFlag : norwegianFlag; + + return ( + + ); +} diff --git a/frontend/src/Components/Navbar/components/LanguageButton/index.ts b/frontend/src/Components/Navbar/components/LanguageButton/index.ts new file mode 100644 index 000000000..aaa8a2aa1 --- /dev/null +++ b/frontend/src/Components/Navbar/components/LanguageButton/index.ts @@ -0,0 +1 @@ +export { LanguageButton } from './LanguageButton'; diff --git a/frontend/src/Components/Navbar/components/index.ts b/frontend/src/Components/Navbar/components/index.ts index 3420fdf2d..c3a5888e0 100644 --- a/frontend/src/Components/Navbar/components/index.ts +++ b/frontend/src/Components/Navbar/components/index.ts @@ -1 +1,3 @@ +export { HamburgerMenu } from './HamburgerMenu'; +export { LanguageButton } from './LanguageButton'; export { NavbarItem } from './NavbarItem'; diff --git a/frontend/src/i18n/i18n.ts b/frontend/src/i18n/i18n.ts index e86c882e4..e9a699b1f 100644 --- a/frontend/src/i18n/i18n.ts +++ b/frontend/src/i18n/i18n.ts @@ -5,6 +5,8 @@ import { LANGUAGES } from './types'; import translationEN from './zod/en/zod.json'; import translationNB from './zod/nb/zod.json'; +export const LOCALSTORAGE_KEY = 'language'; + export const defaultNS = 'common'; export const resources = { @@ -15,7 +17,7 @@ export const resources = { const devSetting = process.env.NODE_ENV === 'development'; use(initReactI18next).init({ - lng: LANGUAGES.NB, + lng: localStorage.getItem(LOCALSTORAGE_KEY) || LANGUAGES.NB, fallbackLng: LANGUAGES.NB, resources: resources, defaultNS: defaultNS, From 986f5b36eab74ff62135550554c0f0ddad5de684 Mon Sep 17 00:00:00 2001 From: Simen Seeberg-Rommetveit <99171937+simensee@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:51:19 +0200 Subject: [PATCH 05/53] 1369 bug incorrect applicant status behavior (#1510) * fix duplicate status bug for applicants * frontend seperate categorisation for called and rejected * add translation * biome linting * remove empty option * biome * typos --- frontend/src/Components/Dropdown/Dropdown.tsx | 18 +++------ .../src/Components/EventQuery/EventQuery.tsx | 3 ++ .../RecruitmentApplicantsStatus.tsx | 20 +++++----- frontend/src/Forms/SamfFormFieldTypes.tsx | 8 +++- .../RecruitmentFormAdminPage.tsx | 2 +- .../RecruitmentPositionOverviewPage.tsx | 39 +++++++++++++++++-- .../ProcessedApplicants.tsx | 3 +- frontend/src/i18n/constants.ts | 3 ++ frontend/src/i18n/translations.ts | 9 +++++ 9 files changed, 75 insertions(+), 30 deletions(-) diff --git a/frontend/src/Components/Dropdown/Dropdown.tsx b/frontend/src/Components/Dropdown/Dropdown.tsx index e0e4e6986..5a8ffc68e 100644 --- a/frontend/src/Components/Dropdown/Dropdown.tsx +++ b/frontend/src/Components/Dropdown/Dropdown.tsx @@ -11,8 +11,8 @@ export type DropDownOption = { export type DropdownProps = { className?: string; classNameSelect?: string; - defaultValue?: DropDownOption; - initialValue?: T; + defaultValue?: DropDownOption; // issue 1089 + value?: T; disableIcon?: boolean; options?: DropDownOption[]; label?: string | ReactElement; @@ -25,7 +25,7 @@ function DropdownInner( { options = [], defaultValue, - initialValue, + value, onChange, className, classNameSelect, @@ -44,7 +44,7 @@ function DropdownInner( * @param e Standard onChange HTML event for dropdown */ function handleChange(e?: ChangeEvent) { - const choice = Number.parseInt(e?.currentTarget.value ?? '0', 10); + const choice = Number.parseInt(e?.currentTarget.value ?? '-1', 10); if (choice >= 0 && choice < options.length) { onChange?.(options[choice].value); } else { @@ -52,13 +52,6 @@ function DropdownInner( } } - let initialIndex = 0; - if (initialValue !== undefined) { - initialIndex = options.findIndex((opt) => opt.value === initialValue); - } else if (defaultValue) { - initialIndex = options.findIndex((opt) => opt.value === defaultValue.value); - } - return (
+
+ + {t(KEY.recruitment_hardtoget_applications)} ({hardtogetApplicants.length}) + + {t(KEY.recruitment_hardtoget_applications_help_text)} + {hardtogetApplicants.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_hardtoget_applications_empty_text)} + + )} +
diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/ProcessedApplicants/ProcessedApplicants.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/ProcessedApplicants/ProcessedApplicants.tsx index 81b2d0b83..f9e6a3d66 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/ProcessedApplicants/ProcessedApplicants.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/ProcessedApplicants/ProcessedApplicants.tsx @@ -7,7 +7,7 @@ import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; import styles from './ProcessedApplicants.module.scss'; -type ProcessedType = 'rejected' | 'withdrawn' | 'accepted'; +type ProcessedType = 'rejected' | 'withdrawn' | 'accepted' | 'hardtoget'; type ProcessedApplicantsProps = { data: RecruitmentApplicationDto[]; @@ -68,6 +68,7 @@ export function ProcessedApplicants({ data, type, revertStateFunction }: Process withdrawn: styles.withdrawn, accepted: styles.accepted, rejected: styles.rejected, + hardtoget: styles.hardtoget, }; return ( diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index 5257d963b..c31cc62ea 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -328,11 +328,14 @@ export const KEY = { recruitment_applicant_top_position: 'recruitment_applicant_top_position', recruitment_withdrawn_applications: 'recruitment_withdrawn_applications', recruitment_rejected_applications: 'recruitment_rejected_applications', + recruitment_hardtoget_applications: 'recruitment_hardtoget_applications', recruitment_accepted_applications: 'recruitment_accepted_applications', recruitment_rejected_applications_help_text: 'recruitment_rejected_applications_help_text', + recruitment_hardtoget_applications_help_text: 'recruitment_hardtoget_applications_help_text', recruitment_accepted_applications_help_text: 'recruitment_accepted_applications_help_text', recruitment_accepted_applications_empty_text: 'recruitment_accepted_applications_empty_text', recruitment_rejected_applications_empty_text: 'recruitment_rejected_applications_empty_text', + recruitment_hardtoget_applications_empty_text: 'recruitment_hardtoget_applications_empty_text', recruitment_withdrawn_applications_empty_text: 'recruitment_withdrawn_applications_empty_text', recruitment_withdrawn: 'recruitment_withdrawn', recruitment_withdraw_application: 'recruitment_withdraw_application', diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 44328d850..80f7a48e8 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -323,13 +323,17 @@ export const nb = prepareTranslations({ [KEY.recruitment_applicant_top_position]: 'Mest ønskede verv', [KEY.recruitment_withdrawn_applications]: 'Trukkede søknader', [KEY.recruitment_rejected_applications]: 'Søkere som får automatisk avslag', + [KEY.recruitment_hardtoget_applications]: 'Søkere som har takket nei til stillingen', [KEY.recruitment_accepted_applications]: 'Søkere vi har tatt opp', [KEY.recruitment_rejected_applications_help_text]: 'Disse vil få en automatisk epost om avslag dersom de ikke får tilbud om et annet verv.', + [KEY.recruitment_hardtoget_applications_help_text]: + 'Disse har takket nei til stillingen og vil dermed ikke få noen avslagsmail.', [KEY.recruitment_accepted_applications_help_text]: 'Disse vil IKKE få en automatisk epost om avslag. Det er derfor veldig viktig å bekrefte at listen er korrekt.', [KEY.recruitment_accepted_applications_empty_text]: 'Ingen søkere er markert som kontaktet.', [KEY.recruitment_rejected_applications_empty_text]: 'Ingen søkere vil få automatisk avslag på epost.', + [KEY.recruitment_hardtoget_applications_empty_text]: 'Ingen søkere har takket nei til stillingen.', [KEY.recruitment_withdrawn_applications_empty_text]: 'Ingen trekte søknader.', [KEY.recruitment_withdrawn]: 'Trukket', [KEY.recruitment_withdraw_application]: 'Trekk søknad', @@ -778,14 +782,19 @@ export const en = prepareTranslations({ [KEY.recruitment_applicant_top_position]: 'Most desired position', [KEY.recruitment_withdrawn_applications]: 'Withdrawn applications', [KEY.recruitment_rejected_applications]: 'Automatically rejected applicants', + [KEY.recruitment_hardtoget_applications]: 'Applicants who declined the position', [KEY.recruitment_accepted_applications]: 'Applicants we have contacted and accepted', [KEY.recruitment_rejected_applications_help_text]: 'These will get an automatic rejection email if they are not accepted for a different position', + [KEY.recruitment_hardtoget_applications_help_text]: + 'These applicants have declined the position and will not receive a rejection email.', [KEY.recruitment_accepted_applications_help_text]: 'These will NOT get an automatic rejection email, important to double check if everyone is accounted for', [KEY.recruitment_accepted_applications_empty_text]: 'No applicants are marked as contacted.', [KEY.recruitment_rejected_applications_empty_text]: 'No applicants are marked to receive an automatic rejection email.', + [KEY.recruitment_hardtoget_applications_empty_text]: 'No applicants have declined the position.', + [KEY.recruitment_withdrawn_applications_empty_text]: 'No withdrawn applications.', [KEY.recruitment_withdrawn]: 'Withdrawn', [KEY.recruitment_withdrawn_message]: 'You have withdrawn your application to this position', From 9146f4935438d1c4843acd58dbd3b1a5a419c835 Mon Sep 17 00:00:00 2001 From: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> Date: Tue, 22 Oct 2024 19:04:35 +0200 Subject: [PATCH 06/53] 1426 fix create position form (#1511) * make creation form work --- backend/samfundet/serializers.py | 9 + .../RecruitmentPositionForm.tsx | 261 ++++++++++++++++++ ...cruitmentPositionFormAdminPage.module.scss | 5 + .../RecruitmentPositionFormAdminPage.tsx | 233 +++------------- frontend/src/api.ts | 6 +- frontend/src/dto.ts | 4 + 6 files changed, 316 insertions(+), 202 deletions(-) create mode 100644 frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 0c3502112..d900e7ae7 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -790,6 +790,15 @@ def _update_interviewers( except (TypeError, KeyError): raise ValidationError('Invalid data for interviewers.') from None + def validate(self, data: dict) -> dict: + gang_id = self.initial_data.get('gang').get('id') + if gang_id: + try: + data['gang'] = Gang.objects.get(id=gang_id) + except Gang.DoesNotExist: + raise serializers.ValidationError('Invalid gang id') from None + return super().validate(data) + def create(self, validated_data: dict) -> RecruitmentPosition: recruitment_position = super().create(validated_data) interviewer_objects = self.initial_data.get('interviewers', []) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx new file mode 100644 index 000000000..89cb38f72 --- /dev/null +++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx @@ -0,0 +1,261 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { z } from 'zod'; +import { + Button, + Checkbox, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Textarea, +} from '~/Components'; +import { postRecruitmentPosition, putRecruitmentPosition } from '~/api'; +import { KEY } from '~/i18n/constants'; +import { reverse } from '~/named-urls'; +import { ROUTES } from '~/routes'; +import { NON_EMPTY_STRING } from '~/schema/strings'; +import styles from './RecruitmentPositionFormAdminPage.module.scss'; + +const schema = z.object({ + name_nb: NON_EMPTY_STRING, + name_en: NON_EMPTY_STRING, + norwegian_applicants_only: z.boolean(), + short_description_nb: NON_EMPTY_STRING, + short_description_en: NON_EMPTY_STRING, + long_description_nb: NON_EMPTY_STRING, + long_description_en: NON_EMPTY_STRING, + is_funksjonaer_position: z.boolean(), + default_application_letter_nb: NON_EMPTY_STRING, + default_application_letter_en: NON_EMPTY_STRING, + tags: NON_EMPTY_STRING, +}); + +type SchemaType = z.infer; + +interface FormProps { + initialData: Partial; + positionId?: string; + recruitmentId?: string; + gangId?: string; +} + +export function RecruitmentPositionForm({ initialData, positionId, recruitmentId, gangId }: FormProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: initialData, + }); + + const submitText = positionId ? t(KEY.common_save) : t(KEY.common_create); + + const onSubmit = (data: SchemaType) => { + const updatedPosition = { + ...data, + gang: { id: Number.parseInt(gangId ?? '') }, + recruitment: recruitmentId ?? '', + interviewers: [], + }; + + const action = positionId + ? putRecruitmentPosition(positionId, updatedPosition) + : postRecruitmentPosition(updatedPosition); + + action + .then(() => { + toast.success(positionId ? t(KEY.common_update_successful) : t(KEY.common_creation_successful)); + navigate( + reverse({ + pattern: ROUTES.frontend.admin_recruitment_gang_position_overview, + urlParams: { recruitmentId, gangId }, + }), + ); + }) + .catch((error) => { + toast.error(t(KEY.common_something_went_wrong)); + console.error(error); + }); + }; + + useEffect(() => { + form.reset(initialData); + }, [initialData, form]); + + return ( +
+ +
+ ( + + {t(KEY.recruitment_norwegian_applicants_only)} + + + + + + )} + /> + +
+ ( + + {`${t(KEY.common_name)} ${t(KEY.common_norwegian)}`} + + + + + + )} + /> + ( + + {`${t(KEY.common_name)} ${t(KEY.common_english)}`} + + + + + + )} + /> +
+ +
+ ( + + {`${t(KEY.common_short_description)} ${t(KEY.common_norwegian)}`} + + + + + + )} + /> + ( + + {`${t(KEY.common_short_description)} ${t(KEY.common_english)}`} + + + + + + )} + /> +
+ +
+ ( + + {`${t(KEY.common_long_description)} ${t(KEY.common_norwegian)}`} + +