@@ -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/HamburgerMenu/HamburgerMenu.module.scss b/frontend/src/Components/Navbar/components/HamburgerMenu/HamburgerMenu.module.scss
index d7c48c88a..024718c1b 100644
--- a/frontend/src/Components/Navbar/components/HamburgerMenu/HamburgerMenu.module.scss
+++ b/frontend/src/Components/Navbar/components/HamburgerMenu/HamburgerMenu.module.scss
@@ -1,5 +1,4 @@
/* stylelint-disable selector-max-class */
-/* stylelint-disable max-nesting-depth */
@import 'src/mixins';
@import 'src/constants';
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 (
+
{
+ i18n.changeLanguage(otherLanguage).then();
+ localStorage.setItem(LOCALSTORAGE_KEY, otherLanguage);
+ }}
+ >
+
+
+ );
+}
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/Components/NumberInput/NumberInput.tsx b/frontend/src/Components/NumberInput/NumberInput.tsx
new file mode 100644
index 000000000..bfa44b165
--- /dev/null
+++ b/frontend/src/Components/NumberInput/NumberInput.tsx
@@ -0,0 +1,136 @@
+import React, { useEffect, useState } from 'react';
+import { Input, type InputProps } from '~/Components';
+
+export interface NumberInputProps extends Omit
{
+ onChange?: (...event: unknown[]) => void;
+ allowDecimal?: boolean;
+ clamp?: boolean;
+}
+
+export const NumberInput = React.forwardRef(
+ ({ onChange, value, type, min, max, allowDecimal = true, clamp = true, onBlur, ...props }, ref) => {
+ const [inputValue, setInputValue] = useState(value || '');
+
+ useEffect(() => {
+ setInputValue(value || '');
+ }, [value]);
+
+ const canClamp = clamp && (min !== undefined || max !== undefined);
+
+ function clampValue(n: number) {
+ if (!canClamp) {
+ return n;
+ }
+ let clamped = n;
+ if (min !== undefined) {
+ clamped = Math.max(clamped, Number(min));
+ }
+ if (max !== undefined) {
+ clamped = Math.min(clamped, Number(max));
+ }
+ return clamped;
+ }
+
+ function handleOnBlur(event: React.FocusEvent) {
+ if (canClamp) {
+ const clamped = clampValue(Number(event.target.value) || 0);
+ if (!Number.isNaN(clamped)) {
+ setInputValue(clamped);
+ onChange?.(clamped);
+ }
+ }
+ onBlur?.(event);
+ }
+
+ function isValidPartial(s: string) {
+ // Allows for partial inputs like "-" or "1."
+ const re = allowDecimal ? /^-?[0-9]*\.?[0-9]*$/ : /^-?[0-9]*$/;
+ return re.test(s);
+ }
+
+ function previewAfterInsert(input: HTMLInputElement, data: string) {
+ const val = input.value;
+ const start = input.selectionStart || 0;
+ const end = input.selectionEnd || 0;
+
+ return val.slice(0, start) + data + val.slice(end);
+ }
+
+ function previewAfterDelete(input: HTMLInputElement, isBackspace: boolean) {
+ const val = input.value;
+ const start = input.selectionStart || 0;
+ const end = input.selectionEnd || 0;
+
+ if (start !== end) {
+ // Range selected, delete selection
+ return val.slice(0, start) + val.slice(end);
+ }
+
+ if (isBackspace) {
+ // No selection, single char deletion
+ return val.slice(0, start - 1) + val.slice(start);
+ }
+ return val.slice(0, start) + val.slice(start + 1);
+ }
+
+ function onBeforeInput(e: React.FormEvent) {
+ const event = e as React.CompositionEvent;
+ if (!event.data) {
+ return;
+ }
+ const preview = previewAfterInsert(event.currentTarget, event.data);
+ if (!isValidPartial(preview)) {
+ e.preventDefault();
+ return;
+ }
+ }
+
+ function onKeyDown(event: React.KeyboardEvent) {
+ if (event.key === 'Backspace' || event.key === 'Delete') {
+ const preview = previewAfterDelete(event.currentTarget, event.key === 'Backspace');
+ if (!isValidPartial(preview)) {
+ event.preventDefault();
+ return;
+ }
+ }
+
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
+ event.preventDefault();
+ const add = event.key === 'ArrowUp' ? 1 : -1;
+ const newVal = clampValue((Number(inputValue) || 0) + add);
+ if (!Number.isNaN(newVal)) {
+ setInputValue(newVal);
+ onChange?.(newVal);
+ }
+ }
+ }
+
+ function handleOnChange(event: React.ChangeEvent) {
+ const val = event.target.value;
+ if (isValidPartial(val)) {
+ setInputValue(val);
+ }
+ // This ensures we don't send partially valid inputs
+ const num = Number(val);
+ if (!Number.isNaN(num)) {
+ onChange?.(num);
+ }
+ }
+
+ return (
+
+ );
+ },
+);
+NumberInput.displayName = 'NumberInput';
diff --git a/frontend/src/Components/NumberInput/index.ts b/frontend/src/Components/NumberInput/index.ts
new file mode 100644
index 000000000..2e1d6a399
--- /dev/null
+++ b/frontend/src/Components/NumberInput/index.ts
@@ -0,0 +1 @@
+export { NumberInput } from './NumberInput';
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/TimeslotButton/TimeslotButton.module.scss b/frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.module.scss
deleted file mode 100644
index 96ed7f8a1..000000000
--- a/frontend/src/Components/OccupiedForm/components/TimeslotButton/TimeslotButton.module.scss
+++ /dev/null
@@ -1,37 +0,0 @@
-@import 'src/constants';
-
-@import 'src/mixins';
-
-.timeslot {
- padding: 0.5rem 1rem;
- border-radius: 5px;
- border: 1px solid $grey-3;
- color: $grey-3;
- cursor: pointer;
- background: none;
- position: relative;
- display: flex;
- align-items: center;
- gap: 0.25rem;
- font-size: 0.9rem;
- font-weight: 500;
-}
-
-.dot {
- $size: 4px;
- width: $size;
- height: $size;
- border-radius: $size;
- display: inline-block;
- background: $grey-3;
-}
-
-.timeslot_active {
- border-color: $red;
- color: $red;
-
- .dot {
- background: $red;
- }
-}
-
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/Pagination/PagedPagination.module.scss b/frontend/src/Components/Pagination/PagedPagination.module.scss
new file mode 100644
index 000000000..fb4dcf1ec
--- /dev/null
+++ b/frontend/src/Components/Pagination/PagedPagination.module.scss
@@ -0,0 +1,6 @@
+.container {
+ display: flex;
+ justify-content: center;
+ margin: 1rem;
+ width: 100%;
+}
diff --git a/frontend/src/Components/Pagination/PagedPagination.stories.tsx b/frontend/src/Components/Pagination/PagedPagination.stories.tsx
new file mode 100644
index 000000000..4d72e528e
--- /dev/null
+++ b/frontend/src/Components/Pagination/PagedPagination.stories.tsx
@@ -0,0 +1,153 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { PagedPagination } from './PagedPagination';
+
+export default {
+ title: 'Components/DRFPagination',
+ component: PagedPagination,
+ argTypes: {
+ currentPage: {
+ control: 'number',
+ description: 'Current active page',
+ },
+ totalItems: {
+ control: 'number',
+ description: 'Total number of items to paginate',
+ },
+ pageSize: {
+ control: 'number',
+ description: 'Number of items per page',
+ },
+ siblingCount: {
+ control: 'number',
+ description: 'Number of sibling pages around the current page',
+ defaultValue: 1,
+ },
+ boundaryCount: {
+ control: 'number',
+ description: 'Number of pages to display at the start and end',
+ defaultValue: 1,
+ },
+ className: {
+ control: 'text',
+ description: 'Custom class for the pagination container',
+ },
+ itemClassName: {
+ control: 'text',
+ description: 'Custom class for individual pagination items',
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: 'A pagination component designed to work with Django Rest Framework pagination.',
+ },
+ },
+ },
+} as ComponentMeta;
+
+// Template with state management
+const Template: ComponentStory = (args) => {
+ const [currentPage, setCurrentPage] = useState(args.currentPage);
+
+ return (
+
+
+
Current page: {currentPage}
+
+ );
+};
+
+// Basic usage
+export const Basic = Template.bind({});
+Basic.args = {
+ currentPage: 1,
+ totalItems: 100,
+ pageSize: 10,
+};
+Basic.parameters = {
+ docs: {
+ description: {
+ story: 'Basic pagination with default styling',
+ },
+ },
+};
+
+// Many pages example
+export const ManyPages = Template.bind({});
+ManyPages.args = {
+ ...Basic.args,
+ totalItems: 2500,
+ currentPage: 7,
+};
+ManyPages.parameters = {
+ docs: {
+ description: {
+ story: 'Pagination with many pages showing ellipsis',
+ },
+ },
+};
+
+// Minimal pages example
+export const MinimalPages = Template.bind({});
+MinimalPages.args = {
+ ...Basic.args,
+ totalItems: 30,
+ pageSize: 10,
+};
+MinimalPages.parameters = {
+ docs: {
+ description: {
+ story: 'Pagination with only a few pages using text theme',
+ },
+ },
+};
+
+// Example with increased sibling count
+export const SiblingCountTwo = Template.bind({});
+SiblingCountTwo.args = {
+ ...Basic.args,
+ totalItems: 250,
+ siblingCount: 2,
+ currentPage: 5,
+};
+SiblingCountTwo.parameters = {
+ docs: {
+ description: {
+ story: 'Pagination showing two sibling pages around the current page.',
+ },
+ },
+};
+
+// Example with increased boundary count
+export const BoundaryCountTwo = Template.bind({});
+BoundaryCountTwo.args = {
+ ...Basic.args,
+ totalItems: 250,
+ boundaryCount: 2,
+ currentPage: 10,
+};
+BoundaryCountTwo.parameters = {
+ docs: {
+ description: {
+ story: 'Pagination with two boundary pages displayed at the start and end.',
+ },
+ },
+};
+
+// Combination of increased sibling and boundary count
+export const SiblingAndBoundary = Template.bind({});
+SiblingAndBoundary.args = {
+ ...Basic.args,
+ totalItems: 250,
+ siblingCount: 2,
+ boundaryCount: 2,
+ currentPage: 12,
+};
+SiblingAndBoundary.parameters = {
+ docs: {
+ description: {
+ story: 'Pagination showing two sibling pages and two boundary pages.',
+ },
+ },
+};
diff --git a/frontend/src/Components/Pagination/PagedPagination.tsx b/frontend/src/Components/Pagination/PagedPagination.tsx
new file mode 100644
index 000000000..2eb18487a
--- /dev/null
+++ b/frontend/src/Components/Pagination/PagedPagination.tsx
@@ -0,0 +1,115 @@
+import { Icon } from '@iconify/react';
+import classNames from 'classnames';
+import { useMemo } from 'react';
+import styles from './PagedPagination.module.scss';
+import { Pagination, PaginationButton, PaginationContent, PaginationEllipsis, PaginationItem } from './components';
+
+type PagedPaginationPaginationItemType = (number | 'ellipsis')[];
+
+interface PagedPaginationnProps {
+ currentPage: number;
+ totalItems: number;
+ pageSize: number;
+ onPageChange: (page: number) => void;
+ siblingCount?: number; // Controls the number of sibling pages around the current page
+ boundaryCount?: number; // Controls the number of boundary pages on each end
+ paginationClassName?: string;
+ itemClassName?: string;
+}
+
+// Helper function to generate sequential page numbers
+const generateSequentialPages = (start: number, end: number): number[] => {
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i);
+};
+
+// Adjusted ellipsis helper functions
+const showStartEllipsis = (current: number, boundaryCount: number, siblingCount: number): boolean =>
+ boundaryCount > 0 && siblingCount > 0 && current > boundaryCount + siblingCount + 1;
+const showEndEllipsis = (current: number, total: number, boundaryCount: number, siblingCount: number): boolean =>
+ boundaryCount > 0 && siblingCount > 0 && current < total - boundaryCount - siblingCount;
+
+export function PagedPagination({
+ currentPage,
+ totalItems,
+ pageSize,
+ onPageChange,
+ siblingCount = 1,
+ boundaryCount = 1,
+ paginationClassName,
+ itemClassName,
+}: PagedPaginationnProps) {
+ const totalPages = Math.ceil(totalItems / pageSize);
+
+ const paginationItems = useMemo(() => {
+ const pages: PagedPaginationPaginationItemType = [];
+ const startPages = generateSequentialPages(1, Math.min(boundaryCount, totalPages));
+ const endPages = generateSequentialPages(Math.max(totalPages - boundaryCount + 1, boundaryCount + 1), totalPages);
+
+ // Early return for simple pagination case
+ if (totalPages <= 7 + siblingCount * 2 + boundaryCount * 2) {
+ return generateSequentialPages(1, totalPages);
+ }
+
+ // Add boundary pages at the start
+ pages.push(...startPages);
+
+ // Conditionally add start ellipsis
+ if (showStartEllipsis(currentPage, boundaryCount, siblingCount)) {
+ pages.push('ellipsis');
+ }
+
+ // Add sibling pages around the current page
+ const startSibling = Math.max(boundaryCount + 1, currentPage - siblingCount);
+ const endSibling = Math.min(totalPages - boundaryCount, currentPage + siblingCount);
+ pages.push(...generateSequentialPages(startSibling, endSibling));
+
+ // Conditionally add end ellipsis
+ if (showEndEllipsis(currentPage, totalPages, boundaryCount, siblingCount)) {
+ pages.push('ellipsis');
+ }
+
+ // Add boundary pages at the end
+ pages.push(...endPages);
+
+ return pages;
+ }, [currentPage, totalPages, siblingCount, boundaryCount]);
+
+ return (
+
+
+
+
+ }
+ onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ />
+
+
+ {paginationItems.map((page, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey:
+
+ {page === 'ellipsis' ? (
+
+ ) : (
+ onPageChange(page)}
+ />
+ )}
+
+ ))}
+
+
+ }
+ onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/Components/Pagination/components/Pagination/Pagination.module.scss b/frontend/src/Components/Pagination/components/Pagination/Pagination.module.scss
new file mode 100644
index 000000000..7385cf5f2
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/Pagination/Pagination.module.scss
@@ -0,0 +1,5 @@
+.nav {
+ display: flex;
+ align-items: center;
+ width: 100%;
+}
diff --git a/frontend/src/Components/Pagination/components/Pagination/Pagination.tsx b/frontend/src/Components/Pagination/components/Pagination/Pagination.tsx
new file mode 100644
index 000000000..eb9624caa
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/Pagination/Pagination.tsx
@@ -0,0 +1,8 @@
+import classNames from 'classnames';
+import React from 'react';
+import styles from './Pagination.module.scss';
+
+export const Pagination = React.forwardRef>(({ className, ...props }, ref) => (
+
+));
+Pagination.displayName = 'Pagination';
diff --git a/frontend/src/Components/Pagination/components/Pagination/index.ts b/frontend/src/Components/Pagination/components/Pagination/index.ts
new file mode 100644
index 000000000..0a1fd4dad
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/Pagination/index.ts
@@ -0,0 +1 @@
+export { Pagination } from './Pagination';
diff --git a/frontend/src/Components/Pagination/components/PaginationButton/PaginationButton.module.scss b/frontend/src/Components/Pagination/components/PaginationButton/PaginationButton.module.scss
new file mode 100644
index 000000000..e2216ef60
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationButton/PaginationButton.module.scss
@@ -0,0 +1,9 @@
+.control {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0.25rem 0.5rem;
+ width: 100%;
+ min-width: 2.5rem;
+ text-align: center;
+}
diff --git a/frontend/src/Components/Pagination/components/PaginationButton/PaginationButton.tsx b/frontend/src/Components/Pagination/components/PaginationButton/PaginationButton.tsx
new file mode 100644
index 000000000..52a867f44
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationButton/PaginationButton.tsx
@@ -0,0 +1,33 @@
+import classNames from 'classnames';
+import type { ReactNode } from 'react';
+import type { ButtonProps } from '~/Components';
+import { Button } from '~/Components/Button';
+import styles from './PaginationButton.module.scss';
+
+type PaginationButtonProps = Omit & {
+ isActive?: boolean;
+ buttonSymbol: string | ReactNode;
+};
+
+export function PaginationButton({
+ isActive,
+ className,
+ buttonSymbol,
+ disabled,
+ onClick,
+ ...props
+}: PaginationButtonProps) {
+ return (
+
+ {buttonSymbol}
+
+ );
+}
diff --git a/frontend/src/Components/Pagination/components/PaginationButton/index.ts b/frontend/src/Components/Pagination/components/PaginationButton/index.ts
new file mode 100644
index 000000000..b05e4836b
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationButton/index.ts
@@ -0,0 +1 @@
+export { PaginationButton } from './PaginationButton';
diff --git a/frontend/src/Components/Pagination/components/PaginationContent/PaginationContent.module.scss b/frontend/src/Components/Pagination/components/PaginationContent/PaginationContent.module.scss
new file mode 100644
index 000000000..44ca9cd9c
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationContent/PaginationContent.module.scss
@@ -0,0 +1,9 @@
+.list {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+}
diff --git a/frontend/src/Components/Pagination/components/PaginationContent/PaginationContent.tsx b/frontend/src/Components/Pagination/components/PaginationContent/PaginationContent.tsx
new file mode 100644
index 000000000..85a6ed677
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationContent/PaginationContent.tsx
@@ -0,0 +1,8 @@
+import classNames from 'classnames';
+import React from 'react';
+import styles from './PaginationContent.module.scss';
+
+export const PaginationContent = React.forwardRef>(
+ ({ className, ...props }, ref) => ,
+);
+PaginationContent.displayName = 'PaginationContent';
diff --git a/frontend/src/Components/Pagination/components/PaginationContent/index.ts b/frontend/src/Components/Pagination/components/PaginationContent/index.ts
new file mode 100644
index 000000000..888737873
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationContent/index.ts
@@ -0,0 +1 @@
+export { PaginationContent } from './PaginationContent';
diff --git a/frontend/src/Components/Pagination/components/PaginationEllipsis/PaginationEllipsis.module.scss b/frontend/src/Components/Pagination/components/PaginationEllipsis/PaginationEllipsis.module.scss
new file mode 100644
index 000000000..b3385e9d5
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationEllipsis/PaginationEllipsis.module.scss
@@ -0,0 +1,6 @@
+.ellipsis {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid transparent;
+}
diff --git a/frontend/src/Components/Pagination/components/PaginationEllipsis/PaginationEllipsis.tsx b/frontend/src/Components/Pagination/components/PaginationEllipsis/PaginationEllipsis.tsx
new file mode 100644
index 000000000..2fc4fba37
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationEllipsis/PaginationEllipsis.tsx
@@ -0,0 +1,13 @@
+import { Icon } from '@iconify/react';
+import classNames from 'classnames';
+import React from 'react';
+import styles from './PaginationEllipsis.module.scss';
+
+export const PaginationEllipsis = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+
+
+ ),
+);
+PaginationEllipsis.displayName = 'PaginationEllipsis';
diff --git a/frontend/src/Components/Pagination/components/PaginationEllipsis/index.ts b/frontend/src/Components/Pagination/components/PaginationEllipsis/index.ts
new file mode 100644
index 000000000..0f477ba85
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationEllipsis/index.ts
@@ -0,0 +1 @@
+export { PaginationEllipsis } from './PaginationEllipsis';
diff --git a/frontend/src/Components/Pagination/components/PaginationItem/PaginationItem.module.scss b/frontend/src/Components/Pagination/components/PaginationItem/PaginationItem.module.scss
new file mode 100644
index 000000000..68f679d04
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationItem/PaginationItem.module.scss
@@ -0,0 +1,8 @@
+.item {
+ display: flex;
+ justify-content: center;
+ min-width: 3rem;
+ max-width: 4rem;
+ flex-basis: 3rem;
+ flex-shrink: 1;
+}
diff --git a/frontend/src/Components/Pagination/components/PaginationItem/PaginationItem.tsx b/frontend/src/Components/Pagination/components/PaginationItem/PaginationItem.tsx
new file mode 100644
index 000000000..95bf297fe
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationItem/PaginationItem.tsx
@@ -0,0 +1,8 @@
+import classNames from 'classnames';
+import React from 'react';
+import styles from './PaginationItem.module.scss';
+
+export const PaginationItem = React.forwardRef>(
+ ({ className, ...props }, ref) => ,
+);
+PaginationItem.displayName = 'PaginationItem';
diff --git a/frontend/src/Components/Pagination/components/PaginationItem/index.ts b/frontend/src/Components/Pagination/components/PaginationItem/index.ts
new file mode 100644
index 000000000..c070a870d
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/PaginationItem/index.ts
@@ -0,0 +1 @@
+export { PaginationItem } from './PaginationItem';
diff --git a/frontend/src/Components/Pagination/components/index.ts b/frontend/src/Components/Pagination/components/index.ts
new file mode 100644
index 000000000..f2c048c75
--- /dev/null
+++ b/frontend/src/Components/Pagination/components/index.ts
@@ -0,0 +1,5 @@
+export { Pagination } from './Pagination';
+export { PaginationButton } from './PaginationButton';
+export { PaginationContent } from './PaginationContent';
+export { PaginationEllipsis } from './PaginationEllipsis';
+export { PaginationItem } from './PaginationItem';
diff --git a/frontend/src/Components/Pagination/index.ts b/frontend/src/Components/Pagination/index.ts
new file mode 100644
index 000000000..0c6f455b5
--- /dev/null
+++ b/frontend/src/Components/Pagination/index.ts
@@ -0,0 +1 @@
+export { PagedPagination } from './PagedPagination';
diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss
index a3f7beac6..4fafd89eb 100644
--- a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss
+++ b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss
@@ -20,22 +20,29 @@
padding: 0;
}
+.interviewField {
+ display: flex;
+ justify-content: center;
+ background-color: $white;
+}
+
.header {
- font-size: 0.7em;
- background-color: $grey-3;
- color: $black;
+ font-size: 1.0em;
+ background-color: $red-samf;
+ padding: 0.5em 0.8em;
+ color: $white;
&:hover {
- background-color: $grey-2;
- color: $black;
+ background-color: $red_samf_hover;
+ color: $white;
}
}
.rows {
- padding: 0.1em 0.2em;
+ padding: 0.2em 0.8em;
}
.pending {
- background-color: $pending;
+ background-color: $white;
}
.top_reserve {
@@ -76,6 +83,11 @@
}
.tableRow {
- border-color: $grey-3;
+ border-color: $grey-35;
border-width: 0.01em;
}
+
+.crud {
+ display: flex;
+ justify-content: center;
+}
diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx
index c8fae9e9b..23103e743 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 { Dropdown, type DropdownOption } 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,17 +20,18 @@ type RecruitmentApplicantsStatusProps = {
gangId: number | string | undefined;
positionId: number | string | undefined;
updateStateFunction: (id: string, data: RecruitmentApplicationStateDto) => void;
+ onInterviewChange: () => void;
};
// TODO add backend to fetch these
-const priorityOptions: DropDownOption[] = [
+const priorityOptions: DropdownOption[] = [
{ label: 'Not Set', value: 0 },
{ label: 'Reserve', value: 1 },
{ label: 'Wanted', value: 2 },
{ label: 'Not Wanted', value: 3 },
];
-const statusOptions: DropDownOption[] = [
+const statusOptions: DropdownOption[] = [
{ label: 'Nothing', value: 0 },
{ label: 'Called and accepted', value: 1 },
{ label: 'Called and rejected', value: 2 },
@@ -49,30 +51,30 @@ export function RecruitmentApplicantsStatus({
gangId,
positionId,
updateStateFunction,
+ onInterviewChange,
}: RecruitmentApplicantsStatusProps) {
const { t } = useTranslation();
const navigate = useCustomNavigate();
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_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 },
- { content: t(KEY.recruitment_recruiter_status), sortable: true, hideSortButton: true },
+ { content: t(KEY.recruitment_applicant), sortable: true, hideSortButton: false },
+ { content: t(KEY.recruitment_priority), sortable: true, hideSortButton: false },
+ { content: t(KEY.recruitment_interview_set), sortable: false, hideSortButton: true },
+ { content: t(KEY.recruitment_interview_time), sortable: true, hideSortButton: false },
+ { content: t(KEY.recruitment_interview_location), sortable: true, hideSortButton: false },
+ { content: t(KEY.recruitment_recruiter_priority), sortable: true, hideSortButton: false },
+ { content: t(KEY.recruitment_recruiter_status), sortable: true, hideSortButton: false },
{ content: t(KEY.recruitment_interview_notes), sortable: false, hideSortButton: true },
];
function updateApplications(id: string, field: string, value: string | number | undefined) {
- if (value) {
- switch (field) {
- case editChoices.update_recruitment_priority:
- updateStateFunction(id, { recruiter_priority: value as number });
- break;
- case editChoices.update_recruitment_status:
- updateStateFunction(id, { recruiter_status: value as number });
- break;
- }
+ switch (field) {
+ case editChoices.update_recruitment_priority:
+ updateStateFunction(id, { recruiter_priority: value as number });
+ break;
+ case editChoices.update_recruitment_status:
+ updateStateFunction(id, { recruiter_status: value as number });
+ break;
}
}
@@ -125,30 +127,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)}
+
),
},
{
@@ -156,7 +162,7 @@ export function RecruitmentApplicantsStatus({
style: applicationStatusStyle,
content: (
{
- navigate({
- url: reverse({
- pattern: ROUTES.frontend.admin_recruitment_gang_position_applicants_interview_notes,
- urlParams: {
- recruitmentId: recruitmentId,
- gangId: gangId,
- positionId: positionId,
- interviewId: application.interview?.id,
- },
- }),
- });
- }
- : undefined
- }
- />
+
+ {
+ navigate({
+ url: reverse({
+ pattern: ROUTES.frontend.admin_recruitment_gang_position_applicants_interview_notes,
+ urlParams: {
+ recruitmentId: recruitmentId,
+ gangId: gangId,
+ positionId: positionId,
+ interviewId: application.interview?.id,
+ },
+ }),
+ });
+ }
+ : undefined
+ }
+ />
+
),
},
],
diff --git a/frontend/src/Components/RecruitmentWithoutInterviewTable/RecruitmentWithoutInterviewTable.tsx b/frontend/src/Components/RecruitmentWithoutInterviewTable/RecruitmentWithoutInterviewTable.tsx
index 3925b2e87..a414c3bc0 100644
--- a/frontend/src/Components/RecruitmentWithoutInterviewTable/RecruitmentWithoutInterviewTable.tsx
+++ b/frontend/src/Components/RecruitmentWithoutInterviewTable/RecruitmentWithoutInterviewTable.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Table } from '~/Components/Table';
import type { RecruitmentUserDto } from '~/dto';
import { KEY } from '~/i18n/constants';
+import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
import { dbT } from '~/utils';
import { InputField } from '../InputField';
@@ -24,7 +25,7 @@ export function RecruitmentWithoutInterviewTable({ applicants }: RecruitmentWith
{ content: t(KEY.common_phonenumber), sortable: true },
{ content: t(KEY.recruitment_applicant_top_position), sortable: true },
{ content: t(KEY.recruitment_number_of_applications), sortable: true },
- { content: t(KEY.common_processed), sortable: true },
+ { content: t(KEY.recruitment_interview_planned), sortable: true },
];
function filterUsers(): RecruitmentUserDto[] {
@@ -54,7 +55,19 @@ export function RecruitmentWithoutInterviewTable({ applicants }: RecruitmentWith
return [
{
value: `${user.first_name} ${user.last_name}`,
- content: {`${user.first_name} ${user.last_name}`},
+ content: (
+
+ {user.first_name} {user.last_name}
+
+ ),
},
user.email,
user.phone_number,
@@ -64,7 +77,8 @@ export function RecruitmentWithoutInterviewTable({ applicants }: RecruitmentWith
value: user.applications_without_interview ? user.applications_without_interview.length : 0,
content: (
),
diff --git a/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewList.tsx b/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewList.tsx
index 3d41bd7a3..e9a3234aa 100644
--- a/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewList.tsx
+++ b/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewList.tsx
@@ -1,7 +1,8 @@
+import { Icon } from '@iconify/react';
import { useTranslation } from 'react-i18next';
-import { Link } from '~/Components';
+import { Link, Text } from '~/Components';
import { Table } from '~/Components/Table';
-import type { RecruitmentApplicationDto } from '~/dto';
+import type { RecruitmentApplicationDto, RecruitmentUserDto } from '~/dto';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
@@ -10,17 +11,23 @@ import styles from './WithoutInterview.module.scss';
type WithoutInterviewListProps = {
applications: RecruitmentApplicationDto[];
+ user: RecruitmentUserDto;
+ applicationsWithoutInterview: RecruitmentApplicationDto[];
};
-export function WithoutInterviewList({ applications }: WithoutInterviewListProps) {
+export function WithoutInterviewList({ applications, user, applicationsWithoutInterview }: WithoutInterviewListProps) {
const { t } = useTranslation();
const tableColumns = [
{ content: t(KEY.recruitment_position), sortable: true },
+
+ { content: t(KEY.recruitment_interview_planned), sortable: true },
{ content: t(KEY.recruitment_priority), sortable: true },
];
function applicationToRow(application: RecruitmentApplicationDto) {
+ const hasInterview = !applicationsWithoutInterview.some((app) => app.id === application.id);
+
return {
cells: [
{
@@ -28,9 +35,11 @@ export function WithoutInterviewList({ applications }: WithoutInterviewListProps
content: (
@@ -38,12 +47,29 @@ export function WithoutInterviewList({ applications }: WithoutInterviewListProps
),
},
+
+ {
+ value: hasInterview ? 1 : 0,
+ content: (
+
+ ),
+ },
application.applicant_priority,
],
};
}
+
return (
+
+ {user.first_name} {user.last_name}
+
applicationToRow(application))} />
);
diff --git a/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewModal.tsx b/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewModal.tsx
index 2318b9e60..db9fc67a8 100644
--- a/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewModal.tsx
+++ b/frontend/src/Components/RecruitmentWithoutInterviewTable/components/WithoutInterviewModal.tsx
@@ -1,21 +1,26 @@
import { useState } from 'react';
import { Button, IconButton, Modal } from '~/Components';
-import type { RecruitmentApplicationDto } from '~/dto';
+import type { RecruitmentApplicationDto, RecruitmentUserDto } from '~/dto';
import styles from './WithoutInterview.module.scss';
import { WithoutInterviewList } from './WithoutInterviewList';
type WithoutInterviewModalProps = {
+ user: RecruitmentUserDto;
applications: RecruitmentApplicationDto[];
- applications_without_interview: RecruitmentApplicationDto[];
+ applicationsWithoutInterview: RecruitmentApplicationDto[];
};
-export function WithoutInterviewModal({ applications, applications_without_interview }: WithoutInterviewModalProps) {
+export function WithoutInterviewModal({
+ applications,
+ applicationsWithoutInterview,
+ user,
+}: WithoutInterviewModalProps) {
const [withoutInterviewModal, setWithoutInterviewModal] = useState(false);
return (
<>
- setWithoutInterviewModal(true)}>
- {applications.length - applications_without_interview.length} / {applications.length}
+ setWithoutInterviewModal(true)}>
+ {applications.length - applicationsWithoutInterview.length} / {applications.length}
setWithoutInterviewModal(false)}
/>
-
+
+
>
);
diff --git a/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.module.scss b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.module.scss
new file mode 100644
index 000000000..d6408c820
--- /dev/null
+++ b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.module.scss
@@ -0,0 +1,14 @@
+@import 'src/constants';
+
+@import 'src/mixins';
+
+.container {
+ width: $primary-content-width-wide;
+ max-width: 100%;
+ margin: 0 auto;
+ padding: 2rem 0.75rem;
+
+ @include for-desktop-up {
+ padding: 6rem 4rem;
+ }
+}
diff --git a/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.tsx b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.tsx
new file mode 100644
index 000000000..9dd58cefc
--- /dev/null
+++ b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.tsx
@@ -0,0 +1,28 @@
+import { useTranslation } from 'react-i18next';
+import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
+import { ErrorDisplay } from '~/Components';
+import { KEY } from '~/i18n/constants';
+import styles from './RootErrorBoundary.module.scss';
+
+export function RootErrorBoundary() {
+ const { t } = useTranslation();
+ const error = useRouteError();
+
+ let errorDisplay = ;
+
+ if (isRouteErrorResponse(error)) {
+ if (error.status === 404) {
+ errorDisplay = ;
+ }
+ if (error.status === 403) {
+ errorDisplay = ;
+ }
+ if (error.status === 500) {
+ errorDisplay = (
+
+ );
+ }
+ }
+
+ return {errorDisplay}
;
+}
diff --git a/frontend/src/Components/RootErrorBoundary/index.ts b/frontend/src/Components/RootErrorBoundary/index.ts
new file mode 100644
index 000000000..8439a2010
--- /dev/null
+++ b/frontend/src/Components/RootErrorBoundary/index.ts
@@ -0,0 +1 @@
+export { RootErrorBoundary } from './RootErrorBoundary';
diff --git a/frontend/src/Components/SamfOutlet/SamfOutlet.tsx b/frontend/src/Components/SamfOutlet/SamfOutlet.tsx
index 70419d4b5..54ac1a888 100644
--- a/frontend/src/Components/SamfOutlet/SamfOutlet.tsx
+++ b/frontend/src/Components/SamfOutlet/SamfOutlet.tsx
@@ -1,15 +1,22 @@
+import type { ReactNode } from 'react';
import { Outlet } from 'react-router-dom';
import { Navbar } from '~/Components/Navbar';
import { Footer } from '../Footer';
import styles from './SamfOutlet.module.scss';
export function SamfOutlet() {
+ return (
+
+
+
+ );
+}
+
+export function SamfLayout({ children }: { children: ReactNode }) {
return (
<>
-
-
-
+ {children}
>
);
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/Components/SetInterviewManually/SetInterviewManually.module.scss b/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss
new file mode 100644
index 000000000..db6fcac59
--- /dev/null
+++ b/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss
@@ -0,0 +1,89 @@
+@import 'src/constants';
+
+@import 'src/mixins';
+
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2em;
+ padding: 0.5em;
+}
+
+.set_interview_button {
+ width: 94%;
+ font-size: small;
+ padding: 8px 17px;
+}
+
+.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)}
+ />
+
+
+ onCancel?.()}>
+ {t(KEY.common_cancel)}
+
+
+ {t(KEY.common_save)}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx
new file mode 100644
index 000000000..ba021575b
--- /dev/null
+++ b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx
@@ -0,0 +1,53 @@
+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(true)}
+ className={styles.set_interview_button}
+ >
+ {t(KEY.recruitment_interview_set)}
+
+
+
+ <>
+ setOpen(false)}>
+
+
+ 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/TabBar/TabBar.module.scss b/frontend/src/Components/TabBar/TabBar.module.scss
index 60918346d..b3d6dbe7b 100644
--- a/frontend/src/Components/TabBar/TabBar.module.scss
+++ b/frontend/src/Components/TabBar/TabBar.module.scss
@@ -43,7 +43,6 @@
border: none;
border-bottom: 3px solid transparent;
- /* stylelint-disable-next-line max-nesting-depth */
@include theme-dark {
color: white;
}
@@ -54,7 +53,6 @@
color: $blue;
box-shadow: inset 0 0 1px 1.5px rgba(0, 0, 0, 0.15);
- /* stylelint-disable-next-line max-nesting-depth */
@include theme-dark {
border-bottom: 3px solid $white;
background-color: $grey-2;
diff --git a/frontend/src/Components/Table/Table.module.scss b/frontend/src/Components/Table/Table.module.scss
index cbbd3d0bf..3c98d8871 100644
--- a/frontend/src/Components/Table/Table.module.scss
+++ b/frontend/src/Components/Table/Table.module.scss
@@ -2,7 +2,7 @@
@import 'src/mixins';
-/* stylelint-disable selector-max-combinators, selector-max-compound-selectors, selector-max-type, max-nesting-depth */
+/* stylelint-disable selector-max-combinators, selector-max-compound-selectors, selector-max-type */
// Backgrounds
$table-bg: #ffffff;
diff --git a/frontend/src/Components/Table/Table.tsx b/frontend/src/Components/Table/Table.tsx
index 87c97895f..22e092547 100644
--- a/frontend/src/Components/Table/Table.tsx
+++ b/frontend/src/Components/Table/Table.tsx
@@ -1,6 +1,6 @@
import { Icon } from '@iconify/react';
import classNames from 'classnames';
-import { useState } from 'react';
+import { Fragment, useState } from 'react';
import { TimeDisplay } from '~/Components';
import type { Children } from '~/types';
import styles from './Table.module.scss';
@@ -191,7 +191,7 @@ export function Table({
if (isColumnSortable(col)) {
return (
+ // biome-ignore lint/suspicious/noArrayIndexKey: no guarantee for unique value except for index
key={index}
className={classNames(headerColumnClassName, styles.sortable_th)}
onClick={() => sort(index)}
@@ -214,7 +214,7 @@ export function Table({
);
}
return (
- // biome-ignore lint/suspicious/noArrayIndexKey:
+ // biome-ignore lint/suspicious/noArrayIndexKey: no guarantee for unique value except for index
{getColumnContent(col)}
@@ -223,41 +223,40 @@ export function Table({
- {sortedData(data).map((row, index1) => (
- <>
+ {sortedData(data).map((row, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: no guarantee for unique value except for index
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: */}
- key={index1}
- onClick={() => (isOpen === index1 ? setIsOpen(null) : setIsOpen(index1))}
+ onClick={() => (isOpen === index ? setIsOpen(null) : setIsOpen(index))}
>
{row.childTable !== undefined && (
- index1
+ index
}`}
>
-
+
)}
- {row?.cells.map((cell, index2) => (
+ {row?.cells.map((cell, cellIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey:
-
+
{getCellContent(cell ?? '')}
))}
- {row.childTable !== undefined && isOpen === index1 && (
+ {row.childTable !== undefined && isOpen === index && (
)}
- >
+
))}
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 93%
rename from frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.module.scss
rename to frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss
index 9674f6298..a44ddc21b 100644
--- a/frontend/src/Components/OccupiedForm/components/TimeslotContainer/TimeslotContainer.module.scss
+++ b/frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss
@@ -11,5 +11,5 @@
.timeslots {
display: grid;
grid-template-columns: repeat(4, 1fr);
- gap: 0.5rem;
+ gap: 0.25rem;
}
diff --git a/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx b/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx
new file mode 100644
index 000000000..3a9ae63f5
--- /dev/null
+++ b/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx
@@ -0,0 +1,188 @@
+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 (
+ {
+ if (event.button !== 0) {
+ // Ignore if not primary mouse button
+ 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/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss
new file mode 100644
index 000000000..f4004c7a5
--- /dev/null
+++ b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss
@@ -0,0 +1,72 @@
+@import 'src/constants';
+
+@import 'src/mixins';
+
+.timeslot {
+ padding: 0.5rem 1rem;
+ border-radius: 5px;
+ border: 1px solid $grey-35;
+ color: inherit;
+ cursor: pointer;
+ background: none;
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+
+ @include theme-dark {
+ border-color: $grey-0;
+ color: $grey-35;
+ }
+}
+
+.dot {
+ $size: 4px;
+ width: $size;
+ height: $size;
+ border-radius: $size;
+ display: inline-block;
+ border: 1px solid $grey-0;
+
+ @include theme-dark {
+ border-color: $grey-2;
+ }
+}
+
+.timeslot_active {
+ border-color: $red;
+ color: $red;
+
+ .dot {
+ background: $red;
+ border-color: $red;
+ }
+
+ @include theme-dark {
+ color: $red;
+ border-color: $red;
+ }
+}
+
+.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 (
+
+
+ );
+}
diff --git a/frontend/src/Components/UkaOutlet/UkaOutlet.module.scss b/frontend/src/Components/UkaOutlet/UkaOutlet.module.scss
new file mode 100644
index 000000000..c7d2f84fe
--- /dev/null
+++ b/frontend/src/Components/UkaOutlet/UkaOutlet.module.scss
@@ -0,0 +1,3 @@
+.deleteme {
+ color: red;
+}
diff --git a/frontend/src/Components/UkaOutlet/UkaOutlet.tsx b/frontend/src/Components/UkaOutlet/UkaOutlet.tsx
new file mode 100644
index 000000000..8ced1ff5b
--- /dev/null
+++ b/frontend/src/Components/UkaOutlet/UkaOutlet.tsx
@@ -0,0 +1,15 @@
+import { Outlet } from 'react-router-dom';
+import { UkaNavbar } from './UkaNavbar';
+import styles from './UkaOutlet.module.scss';
+
+export function UkaOutlet() {
+ return (
+ <>
+
+
+
+
+ {/* */}
+ >
+ );
+}
diff --git a/frontend/src/Components/UkaOutlet/index.ts b/frontend/src/Components/UkaOutlet/index.ts
new file mode 100644
index 000000000..2a81e1fdd
--- /dev/null
+++ b/frontend/src/Components/UkaOutlet/index.ts
@@ -0,0 +1 @@
+export { UkaOutlet } from './UkaOutlet';
diff --git a/frontend/src/Components/index.ts b/frontend/src/Components/index.ts
index 4674b2b1e..e0789330f 100644
--- a/frontend/src/Components/index.ts
+++ b/frontend/src/Components/index.ts
@@ -1,54 +1,64 @@
export { AccessDenied } from './AccessDenied';
export { AdminBox } from './AdminBox';
export { Alert } from './Alert';
+export { BackButton } from './BackButton';
export { Breadcrumb } from './Breadcrumb';
export { Button } from './Button';
-export { BackButton } from './BackButton';
export { Carousel } from './Carousel';
+export { Chart } from './Chart';
export { Checkbox } from './Checkbox';
export { ColorDisplay } from './ColorDisplay';
export { CommandMenu } from './CommandMenu';
export { ContentCard } from './ContentCard';
export { Countdown } from './Countdown';
export { CrudButtons } from './CrudButtons';
+export { DatePicker } from './DatePicker';
export { Dropdown } from './Dropdown';
-export { Error } from './Error';
+export { ErrorDisplay } from './ErrorDisplay';
export { EventCard } from './EventCard';
export { EventQuery } from './EventQuery';
export { ExpandableHeader } from './ExpandableHeader';
export { ExpandableList } from './ExpandableList';
export { Footer } from './Footer';
+export { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useFormField } from './Forms';
export { H1 } from './H1';
export { H2 } from './H2';
export { H3 } from './H3';
export { H4 } from './H4';
export { H5 } from './H5';
export { H6 } from './H6';
-export { Form, FormItem, FormMessage, FormControl, FormLabel, FormField, useFormField } from './Forms';
-export { Input, type InputProps } from './Input';
export { IconButton } from './IconButton';
export { Image } from './Image';
export { ImageCard } from './ImageCard';
export { ImageList } from './ImageList';
export { ImageQuery } from './ImageQuery';
+export { Input, type InputProps } from './Input';
export { InputField } from './InputField';
export { InputFile } from './InputFile';
export { InputTime } from './InputTime';
+export { InterviewFormModal } from './InterviewForm';
+export { IsfitOutlet } from './IsfitOutlet';
export { Link } from './Link';
export { List } from './List';
+export { Logo } from './Logo';
export { MiniCalendar } from './MiniCalendar';
export { Modal } from './Modal';
export { Navbar } from './Navbar';
export { NotificationBadge } from './NotificationBadge';
+export { NumberInput } from './NumberInput';
+export { OccupiedForm, OccupiedFormModal } from './OccupiedForm';
export { OpeningHours } from './OpeningHours';
export { Page } from './Page';
+export { PagedPagination } from './Pagination';
export { PermissionRoute } from './PermissionRoute';
+export { PhoneNumberField } from './PhoneNumberField';
export { ProgressBar } from './ProgressBar';
export { ProtectedRoute } from './ProtectedRoute';
export { PulseEffect } from './PulseEffect';
export { RadioButton } from './RadioButton';
export { RecruitmentApplicantsStatus } from './RecruitmentApplicantsStatus';
export { RecruitmentWithoutInterviewTable } from './RecruitmentWithoutInterviewTable';
+export { RootErrorBoundary } from './RootErrorBoundary';
export { SamfOutlet } from './SamfOutlet';
export { SamfundetLogo } from './SamfundetLogo';
export { SamfundetLogoSpinner } from './SamfundetLogoSpinner';
@@ -62,32 +72,27 @@ export { SultenCard } from './SultenCard';
export { SultenFooter } from './SultenFooter';
export { SultenNavbar } from './SultenNavbar';
export { SultenOutlet } from './SultenOutlet';
+export { TabBar } from './TabBar';
+export { Table } from './Table';
+export { TabView } from './TabView';
+export { Text } from './Text';
+export { Textarea, type TextareaProps } from './Textarea';
export { TextAreaField } from './TextAreaField';
export { ThemeSwitch } from './ThemeSwitch';
export { TimeDisplay } from './TimeDisplay';
export { TimeDuration } from './TimeDuration';
+export { TimeslotContainer } from './TimeslotContainer';
export { ToggleSwitch } from './ToggleSwitch';
+export { ToolTip } from './ToolTip';
+export { UkaOutlet } from './UkaOutlet';
export { UserFeedback } from './UserFeedback';
export { Video } from './Video';
-export { PhoneNumberField } from './PhoneNumberField';
-export { ToolTip } from './ToolTip';
-export { InterviewFormModal } from './InterviewForm';
-export { Chart } from './Chart';
-export { Logo } from './Logo';
-export { TabBar } from './TabBar';
-export { TabView } from './TabView';
-export { Text } from './Text';
-export { type TextareaProps, Textarea } from './Textarea';
-export { Table } from './Table';
-export { OccupiedFormModal } from './OccupiedForm';
-export { OccupiedForm } from './OccupiedForm';
-
// Props
+export type { ButtonProps } from './Button';
export type { CheckboxProps } from './Checkbox';
export type { DropdownProps } from './Dropdown';
export type { ImagePickerProps } from './ImagePicker/ImagePicker';
export type { InputFieldProps } from './InputField';
export type { InputFileProps } from './InputFile';
-export type { TextAreaFieldProps } from './TextAreaField';
export type { Tab } from './TabBar';
export type { TableRow } from './Table';
diff --git a/frontend/src/Forms/SamfForm.stories.tsx b/frontend/src/Forms/SamfForm.stories.tsx
index 9ec674383..705561876 100644
--- a/frontend/src/Forms/SamfForm.stories.tsx
+++ b/frontend/src/Forms/SamfForm.stories.tsx
@@ -1,5 +1,5 @@
import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { AuthContextProvider } from '~/context/AuthContext';
import { SamfForm, type SamfFormProps } from './SamfForm';
import { SamfFormField } from './SamfFormField';
@@ -28,7 +28,7 @@ function validate69(values: BasicFormProps) {
return 'Tallet må være 69';
}
-const options: DropDownOption[] = [
+const options: DropdownOption[] = [
{ label: 'Option 1', value: 'Option 1' },
{ label: 'Option 2', value: 'Option 2' },
];
diff --git a/frontend/src/Forms/SamfFormField.tsx b/frontend/src/Forms/SamfFormField.tsx
index 25299b3eb..ecb057e7c 100644
--- a/frontend/src/Forms/SamfFormField.tsx
+++ b/frontend/src/Forms/SamfFormField.tsx
@@ -1,6 +1,6 @@
import { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { KEY } from '~/i18n/constants';
import {
type FormType,
@@ -72,8 +72,8 @@ export type SamfFormFieldProps = {
hidden?: boolean;
validator?: (state: T) => SamfError;
// Dropdown
- options?: DropDownOption[];
- defaultOption?: DropDownOption;
+ options?: DropdownOption[];
+ defaultOption?: DropdownOption;
onChange?: (value: U) => void;
props?: FieldProps;
};
diff --git a/frontend/src/Forms/SamfFormFieldTypes.tsx b/frontend/src/Forms/SamfFormFieldTypes.tsx
index 09a5a121d..13e4edd78 100644
--- a/frontend/src/Forms/SamfFormFieldTypes.tsx
+++ b/frontend/src/Forms/SamfFormFieldTypes.tsx
@@ -11,12 +11,12 @@ import {
type InputFileProps,
PhoneNumberField,
TextAreaField,
- type TextAreaFieldProps,
} from '~/Components';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { ImagePicker, type ImagePickerProps } from '~/Components/ImagePicker/ImagePicker';
import type { InputFieldType } from '~/Components/InputField/InputField';
import type { InputFileType } from '~/Components/InputFile/InputFile';
+import type { TextAreaFieldProps } from '~/Components/TextAreaField';
import type { ImageDto } from '~/dto';
import type { SamfError } from './SamfForm';
import styles from './SamfForm.module.scss';
@@ -31,7 +31,7 @@ export type FormFieldReturnType =
| string
| number
| boolean
- | DropDownOption
+ | DropdownOption
| ImageDto
| Date
| File
@@ -74,12 +74,12 @@ export type FieldProps =
*/
export type SamfFormFieldArgs = {
value: T; // Current value of field
- onChange(value: T): void; // Callback to change field
+ onChange(value: T | undefined): void; // Callback to change field
error: SamfError;
label?: string; // Text label above the input
// Custom args for options type
- defaultOption?: DropDownOption;
- options?: DropDownOption[];
+ defaultOption?: DropdownOption;
+ options?: DropdownOption[];
props?: FieldProps;
};
@@ -187,14 +187,19 @@ function makeCheckboxInput(args: SamfFormFieldArgs) {
// Options dropdown input
// # issue 1090
-function makeOptionsInput(args: SamfFormFieldArgs>) {
+function makeOptionsInput(args: SamfFormFieldArgs>) {
const errorBoolean = args.error !== false && args.error !== undefined;
return (
+ // @ts-ignore
{...(args.props as DropdownProps)}
defaultValue={args.defaultOption}
options={args.options}
- onChange={args.onChange as (value?: unknown) => void}
+ value={args.value?.value}
+ onChange={(value) => {
+ const selectedOption = args.options?.find((option) => option.value === value);
+ args.onChange(selectedOption ?? args.defaultOption);
+ }}
label={args.label}
error={errorBoolean}
className={styles.input_element}
diff --git a/frontend/src/Pages/ComponentPage/ComponentPage.tsx b/frontend/src/Pages/ComponentPage/ComponentPage.tsx
index 08da0346e..b8d66115c 100644
--- a/frontend/src/Pages/ComponentPage/ComponentPage.tsx
+++ b/frontend/src/Pages/ComponentPage/ComponentPage.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react';
import { Button, Countdown, H1, H2, H3, H4, H5, H6, InputField, ProgressBar, RadioButton, ToolTip } from '~/Components';
import { Checkbox } from '~/Components/Checkbox';
import { Link } from '~/Components/Link';
@@ -15,6 +16,8 @@ import styles from './ComponentPage.module.scss';
* Useful when styling global themes.
*/
export function ComponentPage() {
+ const [showShrimpFishing, setShowShrimpFishing] = useState(false);
+
return (
@@ -32,46 +35,7 @@ export function ComponentPage() {
-
+ ({ label: String(i), value: i }))} />
@@ -91,7 +55,7 @@ export function ComponentPage() {
Checkboxes:
-
+
@@ -149,7 +113,10 @@ export function ComponentPage() {
-
+
setShowShrimpFishing(true)}>
+ Start rekefisking?
+
+ {showShrimpFishing &&
}
);
}
diff --git a/frontend/src/Pages/ComponentPage/ExampleForm.tsx b/frontend/src/Pages/ComponentPage/ExampleForm.tsx
index f2aa727c3..78b3d24db 100644
--- a/frontend/src/Pages/ComponentPage/ExampleForm.tsx
+++ b/frontend/src/Pages/ComponentPage/ExampleForm.tsx
@@ -5,6 +5,7 @@ import { z } from 'zod';
import {
Button,
Checkbox,
+ DatePicker,
Dropdown,
Form,
FormControl,
@@ -13,6 +14,7 @@ import {
FormLabel,
FormMessage,
Input,
+ NumberInput,
} from '~/Components';
import { PASSWORD, USERNAME } from '~/schema/user';
@@ -21,7 +23,14 @@ const schema = z.object({
password: PASSWORD,
organization: z.string().nullish().optional(),
duration: z.number().min(15).max(60),
+ date: z.date(),
confirm: z.boolean().refine((v) => v, 'Påkrevd'),
+ image_file: z
+ .instanceof(File)
+ .refine((file) => file.size < 1024 * 1024 * 2, {
+ message: "File can't be larger than 2 MB",
+ })
+ .nullable(),
});
export function ExampleForm() {
@@ -36,11 +45,13 @@ export function ExampleForm() {
organization: 'uka',
duration: 15,
confirm: false,
+ image_file: null,
},
});
function onSubmit(values: z.infer) {
setSubmitting(true);
+ console.debug(values);
setSerialized(JSON.stringify(values));
setTimeout(() => setSubmitting(false), 600);
}
@@ -61,7 +72,7 @@ export function ExampleForm() {
Brukernavn
-
+
@@ -87,7 +98,7 @@ export function ExampleForm() {
Organisasjon
-
+
@@ -100,7 +111,20 @@ export function ExampleForm() {
Varighet
-
+
+
+
+
+ )}
+ />
+ (
+
+ Dato
+
+
@@ -119,6 +143,25 @@ export function ExampleForm() {
)}
/>
+
+ (
+
+
+ onChange(event.target.files?.[0])}
+ {...fieldProps}
+ />
+
+
+
+ )}
+ />
+
Lagre
diff --git a/frontend/src/Pages/ContributorsPage/components/Contributor/Contributor.tsx b/frontend/src/Pages/ContributorsPage/ContributorItem.tsx
similarity index 90%
rename from frontend/src/Pages/ContributorsPage/components/Contributor/Contributor.tsx
rename to frontend/src/Pages/ContributorsPage/ContributorItem.tsx
index 78dde9035..7348c371e 100644
--- a/frontend/src/Pages/ContributorsPage/components/Contributor/Contributor.tsx
+++ b/frontend/src/Pages/ContributorsPage/ContributorItem.tsx
@@ -7,18 +7,7 @@ import darkAvatar from '~/assets/contributors/default_dark.svg';
import { THEME } from '~/constants';
import { useGlobalContext } from '~/context/GlobalContextProvider';
import { KEY } from '~/i18n/constants';
-
-export type Contributor = {
- name: string;
- from: string;
- to?: string;
- github?: string;
- picture?: string;
- websjef?: {
- from: string;
- to: string;
- };
-};
+import type { Contributor } from './types';
type Props = {
contributor: Contributor;
diff --git a/frontend/src/Pages/ContributorsPage/ContributorsPage.tsx b/frontend/src/Pages/ContributorsPage/ContributorsPage.tsx
index 2d090cc5f..df8645322 100644
--- a/frontend/src/Pages/ContributorsPage/ContributorsPage.tsx
+++ b/frontend/src/Pages/ContributorsPage/ContributorsPage.tsx
@@ -6,19 +6,14 @@ import robines from '~/assets/contributors/robines.jpg';
import snorre98 from '~/assets/contributors/snorre98.jpg';
import sygard from '~/assets/contributors/sygard.jpg';
import { KEY } from '~/i18n/constants';
+import { ContributorItem } from './ContributorItem';
import styles from './ContributorsPage.module.scss';
-import { type Contributor, ContributorItem } from './components';
+import type { Contributor } from './types';
// biome-ignore format: array should not be formatted
const CONTRIBUTORS: Contributor[] = [
// H17
- {
- name: 'Kevin Kristiansen',
- github: 'KevinKristiansen',
- from: 'H17',
- to: 'V20',
- websjef: { from: 'V18', to: 'H18' },
- },
+ { name: 'Kevin Kristiansen', github: 'KevinKristiansen', from: 'H17', to: 'V20', websjef: { from: 'V18', to: 'H18' } },
// H19
{ name: 'Emil Telstad', github: 'emilte', from: 'H19' },
{ name: 'Sigve Røkenes', github: 'evgiz', from: 'H19', to: 'H23', websjef: { from: 'H20', to: 'V21' } },
@@ -39,15 +34,9 @@ const CONTRIBUTORS: Contributor[] = [
{ name: 'Eirik Hoem', github: 'eiriksho', from: 'H22', to: 'V23' },
{ name: 'Simen Seeberg-Rommetveit', github: 'simensee', from: 'H22' },
// V23
- {
- name: 'Robin Espinosa Jelle',
- github: 'robines',
- from: 'V23',
- picture: robines,
- websjef: { from: 'H23', to: 'V24' },
- },
+ { name: 'Robin Espinosa Jelle', github: 'robines', from: 'V23', picture: robines, websjef: { from: 'H23', to: 'V24' } },
{ name: 'Johanne Grønlien Gjedrem', github: 'johannegg', from: 'V23' },
- { name: 'Tinius Presterud', github: 'tiniuspre', from: 'V23' },
+ { name: 'Tinius Presterud', github: 'tiniuspre', from: 'V23', to: 'V24' },
// H23
{ name: 'Amalie Johansen Vik', github: 'amaliejvik', from: 'H23', picture: amaliejvik },
{ name: 'Marion Lystad', github: 'marionlys', from: 'H23' },
diff --git a/frontend/src/Pages/ContributorsPage/components/Contributor/index.ts b/frontend/src/Pages/ContributorsPage/components/Contributor/index.ts
deleted file mode 100644
index 688926668..000000000
--- a/frontend/src/Pages/ContributorsPage/components/Contributor/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { type Contributor, ContributorItem } from './Contributor';
diff --git a/frontend/src/Pages/ContributorsPage/components/index.ts b/frontend/src/Pages/ContributorsPage/components/index.ts
deleted file mode 100644
index 688926668..000000000
--- a/frontend/src/Pages/ContributorsPage/components/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { type Contributor, ContributorItem } from './Contributor';
diff --git a/frontend/src/Pages/ContributorsPage/types.ts b/frontend/src/Pages/ContributorsPage/types.ts
new file mode 100644
index 000000000..69350b0f6
--- /dev/null
+++ b/frontend/src/Pages/ContributorsPage/types.ts
@@ -0,0 +1,11 @@
+export type Contributor = {
+ name: string;
+ from: string;
+ to?: string;
+ github?: string;
+ picture?: string;
+ websjef?: {
+ from: string;
+ to: string;
+ };
+};
diff --git a/frontend/src/Pages/LycheReservationPage/LycheReservationPage.tsx b/frontend/src/Pages/LycheReservationPage/LycheReservationPage.tsx
index 029213ca6..12b02ff51 100644
--- a/frontend/src/Pages/LycheReservationPage/LycheReservationPage.tsx
+++ b/frontend/src/Pages/LycheReservationPage/LycheReservationPage.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { Link } from '~/Components/Link/Link';
import { SultenPage } from '~/Components/SultenPage';
import { SamfForm } from '~/Forms/SamfForm';
@@ -31,19 +31,19 @@ export function LycheReservationPage() {
const [reservation, setReservation] = useState();
const [availableDate, setAvailableDate] = useState(false);
- const occasionOptions: DropDownOption[] = [
+ const occasionOptions: DropdownOption[] = [
{ value: 'DRINK', label: 'drikke' },
{ value: 'EAT', label: 'spise' },
];
- const hoursOptions: DropDownOption[] = [
+ const hoursOptions: DropdownOption[] = [
{ value: '12:00', label: '12:00' },
{ value: '13:00', label: '13:00' },
{ value: '14:00', label: '14:00' },
{ value: '15:00', label: '15:00' },
];
- const occupancyOptions: DropDownOption[] = [
+ const occupancyOptions: DropdownOption[] = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
diff --git a/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPosition/GangPosition.tsx b/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPosition/GangPosition.tsx
index e13d58a72..e22c843c0 100644
--- a/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPosition/GangPosition.tsx
+++ b/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPosition/GangPosition.tsx
@@ -28,7 +28,7 @@ export function GangPosition({ type, recruitmentPositions }: GangItemProps) {
@@ -37,7 +37,7 @@ export function GangPosition({ type, recruitmentPositions }: GangItemProps) {
diff --git a/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPositionDropdown/GangPositionDropdown.tsx b/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPositionDropdown/GangPositionDropdown.tsx
index 5a963ec05..931798285 100644
--- a/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPositionDropdown/GangPositionDropdown.tsx
+++ b/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangPositionDropdown/GangPositionDropdown.tsx
@@ -7,12 +7,13 @@ import styles from './GangPositionDropdown.module.scss';
type GangItemProps = {
type: GangTypeDto;
+ recruitmentId?: string;
recruitmentPositions?: RecruitmentPositionDto[];
};
//TODO: DO IN ISSUE #1121, only get gang types recruiting from backend
// TODO: so the filtering should be done from the backend
-export function GangPositionDropdown({ type, recruitmentPositions }: GangItemProps) {
+export function GangPositionDropdown({ type, recruitmentPositions, recruitmentId }: GangItemProps) {
const filteredGangs = type.gangs
.map((gang) => {
const filteredPositions = recruitmentPositions?.filter((pos) => pos.gang.id === gang.id);
@@ -30,7 +31,7 @@ export function GangPositionDropdown({ type, recruitmentPositions }: GangItemPro
@@ -39,7 +40,7 @@ export function GangPositionDropdown({ type, recruitmentPositions }: GangItemPro
diff --git a/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangTypeContainer/GangTypeContainer.tsx b/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangTypeContainer/GangTypeContainer.tsx
index 1f63bc918..ccfdf7185 100644
--- a/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangTypeContainer/GangTypeContainer.tsx
+++ b/frontend/src/Pages/OrganizationRecruitmentPage/Components/GangTypeContainer/GangTypeContainer.tsx
@@ -1,20 +1,18 @@
import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
import { SamfundetLogoSpinner } from '~/Components';
import { getGangList, getRecruitmentPositions } from '~/api';
import type { GangTypeDto, RecruitmentPositionDto } from '~/dto';
import { GangPositionDropdown } from '../GangPositionDropdown';
-type GangTypeContainerProps = {
- recruitmentID: string;
-};
-
-export function GangTypeContainer({ recruitmentID = '-1' }: GangTypeContainerProps) {
+export function GangTypeContainer() {
const [recruitmentPositions, setRecruitmentPositions] = useState();
const [recruitingGangTypes, setRecruitingGangs] = useState();
const [loading, setLoading] = useState(true);
-
+ const { recruitmentId } = useParams();
useEffect(() => {
- Promise.all([getRecruitmentPositions(recruitmentID), getGangList()])
+ if (!recruitmentId) return;
+ Promise.all([getRecruitmentPositions(recruitmentId), getGangList()])
.then(([recruitmentRes, gangsRes]) => {
setRecruitmentPositions(recruitmentRes.data);
setRecruitingGangs(gangsRes);
@@ -24,14 +22,19 @@ export function GangTypeContainer({ recruitmentID = '-1' }: GangTypeContainerPro
console.error('Error fetching data:', error);
setLoading(false);
});
- }, [recruitmentID]);
+ }, [recruitmentId]);
return loading ? (
) : (
<>
{recruitingGangTypes?.map((gangType) => (
-
+
))}
>
);
diff --git a/frontend/src/Pages/OrganizationRecruitmentPage/Components/PositionsTable/PositionsTable.tsx b/frontend/src/Pages/OrganizationRecruitmentPage/Components/PositionsTable/PositionsTable.tsx
index 8783344c6..eb0eaf61c 100644
--- a/frontend/src/Pages/OrganizationRecruitmentPage/Components/PositionsTable/PositionsTable.tsx
+++ b/frontend/src/Pages/OrganizationRecruitmentPage/Components/PositionsTable/PositionsTable.tsx
@@ -21,15 +21,15 @@ export function PositionsTable({ currentSelectedGang, setLoading, loading }: Pos
const [positions, setPositions] = useState([]);
const navigate = useNavigate();
const { t } = useTranslation();
- const recruitmentID = useParams();
+ const { recruitmentId } = useParams();
const { organizationTheme } = useOrganizationContext();
useEffect(() => {
- if (!currentSelectedGang || !recruitmentID.recruitmentID) {
+ if (!currentSelectedGang || !recruitmentId) {
return;
}
setLoading(true);
- getRecruitmentPositionsGangForApplicant(recruitmentID.recruitmentID, currentSelectedGang.id)
+ getRecruitmentPositionsGangForApplicant(recruitmentId, currentSelectedGang.id)
.then((response) => {
setPositions(response.data);
setLoading(false);
@@ -38,7 +38,7 @@ export function PositionsTable({ currentSelectedGang, setLoading, loading }: Pos
console.error('Error fetching data:', error);
setLoading(false);
});
- }, [currentSelectedGang, recruitmentID, setLoading]);
+ }, [currentSelectedGang, recruitmentId, setLoading]);
const tableColumns = [
{ content: t(KEY.common_name), sortable: true },
@@ -48,7 +48,7 @@ export function PositionsTable({ currentSelectedGang, setLoading, loading }: Pos
const tableData = positions.map((item) => {
const positionPageURL = reverse({
pattern: ROUTES.frontend.recruitment_application,
- urlParams: { positionID: item.id, gangID: item.id },
+ urlParams: { positionId: item.id, recruitmentId: recruitmentId },
});
return {
cells: [
diff --git a/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.module.scss b/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.module.scss
index 129b89216..684795018 100644
--- a/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.module.scss
+++ b/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.module.scss
@@ -20,6 +20,7 @@
width: 100%;
padding: 0.25rem;
border-radius: 0.15rem;
+ color: white;
}
.video {
diff --git a/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.tsx b/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.tsx
index f74ae025b..eb0b49c75 100644
--- a/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.tsx
+++ b/frontend/src/Pages/OrganizationRecruitmentPage/OrganizationRecruitmentPage.tsx
@@ -7,7 +7,7 @@ import { PersonalRow } from '~/Pages/RecruitmentPage';
import { getOrganization, getRecruitment } from '~/api';
import { useOrganizationContext } from '~/context/OrgContextProvider';
import type { RecruitmentDto } from '~/dto';
-import { useDesktop } from '~/hooks';
+import { useDesktop, useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { OrgNameType, type OrgNameTypeValue } from '~/types';
import { dbT, getObjectFieldOrNumber } from '~/utils';
@@ -16,18 +16,18 @@ import styles from './OrganizationRecruitmentPage.module.scss';
export function OrganizationRecruitmentPage() {
const isDesktop = useDesktop();
- const embededId = '-nYQb8_TvQ4'; // TODO: Make this dynamic DO IN ISSUE #1121 for backend. #1274 for frontend
- const { recruitmentID } = useParams<{ recruitmentID: string }>();
+ const { recruitmentId } = useParams<{ recruitmentId: string }>();
const [viewAllPositions, setViewAllPositions] = useState(true);
const { t } = useTranslation();
const { changeOrgTheme, organizationTheme } = useOrganizationContext();
const [recruitment, setRecruitment] = useState();
const [organizationName, setOrganizationName] = useState(OrgNameType.FALLBACK);
const [loading, setLoading] = useState(true);
+ useTitle(dbT(recruitment, 'name') ?? '');
useEffect(() => {
- if (recruitmentID) {
- getRecruitment(recruitmentID)
+ if (recruitmentId) {
+ getRecruitment(recruitmentId)
.then((response) => {
setRecruitment(response.data);
})
@@ -35,7 +35,7 @@ export function OrganizationRecruitmentPage() {
console.error(error);
});
}
- }, [recruitmentID]);
+ }, [recruitmentId]);
useEffect(() => {
if (recruitment) {
@@ -76,9 +76,9 @@ export function OrganizationRecruitmentPage() {
{dbT(recruitment, 'name')}
- {embededId ? (
+ {recruitment?.promo_media ? (
<>
-
+
>
) : (
<>>
@@ -96,11 +96,11 @@ export function OrganizationRecruitmentPage() {
- {recruitmentID && (
+ {recruitmentId && (
<>
-
+
@@ -113,8 +113,7 @@ export function OrganizationRecruitmentPage() {
Placeholder for tag-autocomplete search
{/*^^^ issue #1275 */}
- {recruitmentID &&
- (viewAllPositions ?
:
)}
+ {recruitmentId && (viewAllPositions ?
:
)}
{recruitment?.separate_positions && recruitment.separate_positions.length > 0 && (
)}
diff --git a/frontend/src/Pages/RecruitmentApplicationFormPage/RecruitmentApplicationFormPage.tsx b/frontend/src/Pages/RecruitmentApplicationFormPage/RecruitmentApplicationFormPage.tsx
index e5b0bb898..fe6f0d720 100644
--- a/frontend/src/Pages/RecruitmentApplicationFormPage/RecruitmentApplicationFormPage.tsx
+++ b/frontend/src/Pages/RecruitmentApplicationFormPage/RecruitmentApplicationFormPage.tsx
@@ -40,13 +40,13 @@ export function RecruitmentApplicationFormPage() {
const [loading, setLoading] = useState(true);
- const { positionID } = useParams();
+ const { positionId, recruitmentId } = useParams();
useTitle(recruitmentPosition ? (dbT(recruitmentPosition, 'name') as string) : '');
useEffect(() => {
Promise.allSettled([
- getRecruitmentPositionForApplicant(positionID as string)
+ getRecruitmentPositionForApplicant(positionId as string)
.then((res) => {
setRecruitmentPosition(res.data);
})
@@ -57,14 +57,14 @@ export function RecruitmentApplicationFormPage() {
toast.error(t(KEY.common_something_went_wrong));
console.error(error);
}),
- getRecruitmentApplicationForPosition(positionID as string).then((res) => {
+ getRecruitmentApplicationForPosition(positionId as string).then((res) => {
setRecruitmentApplication(res.data);
console.log(res.data);
}),
]).then(() => {
setLoading(false);
});
- }, [positionID, standardNavigate, t]);
+ }, [positionId, standardNavigate, t]);
useEffect(() => {
getRecruitmentPositionsGangForApplicant(
@@ -76,14 +76,14 @@ export function RecruitmentApplicationFormPage() {
}, [recruitmentPosition]);
function withdrawApplication() {
- if (positionID) {
- withdrawRecruitmentApplicationApplicant(positionID)
+ if (positionId) {
+ withdrawRecruitmentApplicationApplicant(positionId)
.then(() => {
navigate({
url: reverse({
pattern: ROUTES.frontend.recruitment_application_overview,
urlParams: {
- recruitmentID: recruitmentPosition?.recruitment,
+ recruitmentId: recruitmentPosition?.recruitment,
},
}),
});
@@ -96,13 +96,13 @@ export function RecruitmentApplicationFormPage() {
}
function handleOnSubmit(data: FormProps) {
- putRecruitmentApplication(data as Partial
, positionID ? +positionID : 1)
+ putRecruitmentApplication(data as Partial, positionId ? +positionId : 1)
.then(() => {
navigate({
url: reverse({
pattern: ROUTES.frontend.recruitment_application_overview,
urlParams: {
- recruitmentID: recruitmentPosition?.recruitment,
+ recruitmentId: recruitmentPosition?.recruitment,
},
}),
});
@@ -121,7 +121,7 @@ export function RecruitmentApplicationFormPage() {
);
}
- if (!positionID || Number.isNaN(Number(positionID))) {
+ if (!positionId || Number.isNaN(Number(positionId))) {
return (
@@ -175,7 +175,7 @@ export function RecruitmentApplicationFormPage() {
navigate({
url: reverse({
pattern: ROUTES.frontend.recruitment_application,
- urlParams: { positionID: pos.id, gangID: pos.gang.id },
+ urlParams: { positionId: pos.id, recruitmentId: recruitmentId },
}),
});
}}
diff --git a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx
index 176584e55..c6d3cebc3 100644
--- a/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx
+++ b/frontend/src/Pages/RecruitmentApplicationsOverviewPage/RecruitmentApplicationsOverviewPage.tsx
@@ -1,12 +1,17 @@
import { Icon } from '@iconify/react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { useParams } from 'react-router-dom';
+import { useNavigate, useParams } from 'react-router-dom';
+import { toast } from 'react-toastify';
import { Button, Link, Page } from '~/Components';
import { OccupiedFormModal } from '~/Components/OccupiedForm';
import { Table } from '~/Components/Table';
import { Text } from '~/Components/Text/Text';
-import { getRecruitmentApplicationsForApplicant, putRecruitmentPriorityForUser } from '~/api';
+import {
+ getRecruitmentApplicationsForApplicant,
+ putRecruitmentPriorityForUser,
+ withdrawRecruitmentApplicationApplicant,
+} from '~/api';
import type { RecruitmentApplicationDto, UserPriorityDto } from '~/dto';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
@@ -15,9 +20,10 @@ import { dbT, niceDateTime } from '~/utils';
import styles from './RecruitmentApplicationsOverviewPage.module.scss';
export function RecruitmentApplicationsOverviewPage() {
- const { recruitmentID } = useParams();
+ const { recruitmentId } = useParams();
const [applications, setApplications] = useState
([]);
const [withdrawnApplications, setWithdrawnApplications] = useState([]);
+ const navigate = useNavigate();
const { t } = useTranslation();
@@ -38,13 +44,13 @@ export function RecruitmentApplicationsOverviewPage() {
}
useEffect(() => {
- if (recruitmentID) {
- getRecruitmentApplicationsForApplicant(recruitmentID).then((response) => {
+ if (recruitmentId) {
+ getRecruitmentApplicationsForApplicant(recruitmentId).then((response) => {
setApplications(response.data.filter((application) => !application.withdrawn));
setWithdrawnApplications(response.data.filter((application) => application.withdrawn));
});
}
- }, [recruitmentID]);
+ }, [recruitmentId]);
const tableColumns = [
{ sortable: false, content: t(KEY.recruitment_position) },
@@ -52,6 +58,7 @@ export function RecruitmentApplicationsOverviewPage() {
{ sortable: false, content: t(KEY.recruitment_interview_location) },
{ sortable: true, content: t(KEY.recruitment_priority) },
{ sortable: false, content: '' },
+ { sortable: false, content: '' },
];
function applicationToTableRow(application: RecruitmentApplicationDto) {
@@ -62,8 +69,8 @@ export function RecruitmentApplicationsOverviewPage() {
url={reverse({
pattern: ROUTES.frontend.recruitment_application,
urlParams: {
- positionID: application.recruitment_position.id,
- gangID: application.recruitment_position.gang.id,
+ positionId: application.recruitment_position.id,
+ recruitmentId: recruitmentId,
},
})}
className={styles.position_name}
@@ -88,7 +95,28 @@ export function RecruitmentApplicationsOverviewPage() {
),
},
];
- return [...position, ...(application.withdrawn ? withdrawn : notWithdrawn)];
+ const widthdrawButton = {
+ content: (
+ {
+ if (window.confirm(t(KEY.recruitment_withdraw_application))) {
+ withdrawRecruitmentApplicationApplicant(application.recruitment_position.id)
+ .then(() => {
+ // redirect to the same page to refresh the data
+ navigate(0);
+ })
+ .catch(() => {
+ toast.error(t(KEY.common_something_went_wrong));
+ });
+ }
+ }}
+ >
+ {t(KEY.recruitment_withdraw_application)}
+
+ ),
+ };
+ return [...position, ...(application.withdrawn ? withdrawn : notWithdrawn), widthdrawButton];
}
const withdrawnTableColumns = [{ sortable: true, content: t(KEY.recruitment_withdrawn) }];
@@ -102,8 +130,8 @@ export function RecruitmentApplicationsOverviewPage() {
url={reverse({
pattern: ROUTES.frontend.recruitment_application,
urlParams: {
- positionID: application.recruitment_position.id,
- gangID: application.recruitment_position.gang.id,
+ positionId: application.recruitment_position.id,
+ recruitmentId: recruitmentId,
},
})}
className={styles.withdrawnLink}
@@ -136,7 +164,7 @@ export function RecruitmentApplicationsOverviewPage() {
{t(KEY.recruitment_not_applied)}
)}
-
+
{withdrawnApplications.length > 0 && (
diff --git a/frontend/src/Pages/RecruitmentPage/Components/PersonalRow/PersonalRow.tsx b/frontend/src/Pages/RecruitmentPage/Components/PersonalRow/PersonalRow.tsx
index 88e9f450c..bfc219d9f 100644
--- a/frontend/src/Pages/RecruitmentPage/Components/PersonalRow/PersonalRow.tsx
+++ b/frontend/src/Pages/RecruitmentPage/Components/PersonalRow/PersonalRow.tsx
@@ -7,12 +7,12 @@ import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
type PersonalRowProps = {
- recruitmentID: string;
+ recruitmentId: string;
organizationName: string;
showRecruitmentBtn?: boolean;
};
-export function PersonalRow({ recruitmentID, organizationName, showRecruitmentBtn = true }: PersonalRowProps) {
+export function PersonalRow({ recruitmentId, organizationName, showRecruitmentBtn = true }: PersonalRowProps) {
const navigate = useCustomNavigate();
const { user } = useAuthContext();
@@ -25,7 +25,7 @@ export function PersonalRow({ recruitmentID, organizationName, showRecruitmentBt
navigate({
url: reverse({
pattern: ROUTES.frontend.organization_recruitment,
- urlParams: { recruitmentID },
+ urlParams: { recruitmentId },
}),
});
}}
@@ -40,7 +40,7 @@ export function PersonalRow({ recruitmentID, organizationName, showRecruitmentBt
navigate({
url: reverse({
pattern: ROUTES.frontend.recruitment_application_overview,
- urlParams: { recruitmentID },
+ urlParams: { recruitmentId },
}),
});
}}
diff --git a/frontend/src/Pages/RecruitmentPage/Components/RecruitmentCard/RecruitmentCard.tsx b/frontend/src/Pages/RecruitmentPage/Components/RecruitmentCard/RecruitmentCard.tsx
index c06f2f5ff..87c8eb011 100644
--- a/frontend/src/Pages/RecruitmentPage/Components/RecruitmentCard/RecruitmentCard.tsx
+++ b/frontend/src/Pages/RecruitmentPage/Components/RecruitmentCard/RecruitmentCard.tsx
@@ -118,7 +118,7 @@ export function RecruitmentCard({
)}
);
diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx
index 55dd59e4d..8c51fef0b 100644
--- a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx
+++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Button, ImageCard } from '~/Components';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { type Tab, TabBar } from '~/Components/TabBar/TabBar';
import { SamfForm } from '~/Forms/SamfForm';
import { SamfFormField } from '~/Forms/SamfFormField';
@@ -61,11 +61,11 @@ export function EventCreatorAdminPage() {
const { id } = useParams();
// TODO these are temporary and must be fetched from API when implemented.
- const eventCategoryOptions: DropDownOption[] = [
+ const eventCategoryOptions: DropdownOption[] = [
{ value: 'concert', label: 'Konsert' },
{ value: 'debate', label: 'Debatt' },
];
- const ageLimitOptions: DropDownOption[] = [
+ const ageLimitOptions: DropdownOption[] = [
{ value: 'none', label: 'Ingen' },
{ value: 'eighteen', label: '18 år' },
{ value: 'twenty', label: '20 år' },
diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/components/PaymentForm.tsx b/frontend/src/PagesAdmin/EventCreatorAdminPage/components/PaymentForm.tsx
index 13a294925..e83ec0577 100644
--- a/frontend/src/PagesAdmin/EventCreatorAdminPage/components/PaymentForm.tsx
+++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/components/PaymentForm.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { SamfFormField } from '~/Forms/SamfFormField';
import type { EventCustomTicketDto, EventDto } from '~/dto';
import { KEY } from '~/i18n/constants';
@@ -18,7 +18,7 @@ export function PaymentForm({ event, onChange }: PaymentFormProps) {
const { t } = useTranslation();
// Dropdown for price group
- const ticketTypeOptions: DropDownOption[] = ALL_TICKET_TYPES.map((ticketType) => {
+ const ticketTypeOptions: DropdownOption[] = ALL_TICKET_TYPES.map((ticketType) => {
return {
value: ticketType,
label: t(getTicketTypeKey(ticketType)) ?? '',
diff --git a/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx b/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx
index 454ce60b6..4ccd4ede0 100644
--- a/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx
+++ b/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx
@@ -18,8 +18,8 @@ const schema = z.object({
name_en: NAME.min(1),
abbreviation: ABBREVIATION.optional().or(z.literal('')),
website: WEBSITE_URL.or(z.literal('')),
- info_page: GANG_INFO_PAGE.optional(),
- gang_type: GANG_TYPE.optional(),
+ info_page: GANG_INFO_PAGE.nullish().or(z.literal('')),
+ gang_type: GANG_TYPE.nullish(),
});
type Props = {
@@ -68,6 +68,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) {
abbreviation: gang?.abbreviation ?? '',
website: gang?.webpage ?? '',
info_page: gang?.info_page,
+ gang_type: gang?.gang_type,
},
});
@@ -103,7 +104,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) {
{lowerCapitalize(`${t(KEY.common_norwegian)} ${t(KEY.common_name)}`)}
-
+
@@ -116,7 +117,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) {
{lowerCapitalize(`${t(KEY.common_english)} ${t(KEY.common_name)}`)}
-
+
@@ -131,7 +132,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) {
{lowerCapitalize(t(KEY.admin_gangsadminpage_abbreviation))}
-
+
@@ -144,7 +145,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) {
{lowerCapitalize(t(KEY.admin_gangsadminpage_webpage))}
-
+
@@ -162,11 +163,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) {
{loadingInfoPages ? (
<>{t(KEY.common_loading)}...>
) : (
- o.value === gang?.info_page)}
- options={infoPageOptions}
- {...field}
- />
+
)}
@@ -183,11 +180,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) {
{loadingGangTypes ? (
<>{t(KEY.common_loading)}...>
) : (
- o.value === gang?.gang_type)}
- options={gangTypeOptions}
- {...field}
- />
+
)}
diff --git a/frontend/src/PagesAdmin/InformationAdminPage/InformationAdminPage.tsx b/frontend/src/PagesAdmin/InformationAdminPage/InformationAdminPage.tsx
index 63da9f401..b2640fb00 100644
--- a/frontend/src/PagesAdmin/InformationAdminPage/InformationAdminPage.tsx
+++ b/frontend/src/PagesAdmin/InformationAdminPage/InformationAdminPage.tsx
@@ -7,6 +7,7 @@ import { deleteInformationPage, getInformationPages } from '~/api';
import { useCustomNavigate, useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
+import { infoPageKeys } from '~/queryKeys';
import { ROUTES } from '~/routes';
import { dbT, lowerCapitalize } from '~/utils';
import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout';
@@ -18,7 +19,7 @@ export function InformationAdminPage() {
// TODO: add permissions on render
const { data, isLoading } = useQuery({
- queryKey: ['informationpages'],
+ queryKey: infoPageKeys.all,
queryFn: getInformationPages,
});
diff --git a/frontend/src/PagesAdmin/InterviewNotesAdminPage/InterviewNotesAdminPage.tsx b/frontend/src/PagesAdmin/InterviewNotesAdminPage/InterviewNotesAdminPage.tsx
index 17a0c9d3f..011e6a765 100644
--- a/frontend/src/PagesAdmin/InterviewNotesAdminPage/InterviewNotesAdminPage.tsx
+++ b/frontend/src/PagesAdmin/InterviewNotesAdminPage/InterviewNotesAdminPage.tsx
@@ -6,6 +6,7 @@ import { Button } from '~/Components';
import { TextAreaField } from '~/Components/TextAreaField/TextAreaField';
import { getRecruitmentApplicationsForGang, putRecruitmentApplicationInterview } from '~/api';
import type { InterviewDto, RecruitmentApplicationDto } from '~/dto';
+import { useTitle } from '~/hooks';
import { STATUS } from '~/http_status_codes';
import { KEY } from '~/i18n/constants';
import { ROUTES } from '~/routes';
@@ -25,6 +26,8 @@ export function InterviewNotesPage() {
const [nameUser, setNameUser] = useState('');
const { t } = useTranslation();
const navigate = useNavigate();
+ const title = t(KEY.recruitment_interview_notes);
+ useTitle(title);
// biome-ignore lint/correctness/useExhaustiveDependencies: t does not need to be in deplist
useEffect(() => {
@@ -73,7 +76,7 @@ export function InterviewNotesPage() {
}
return (
-
+
{t(KEY.recruitment_applicant)}: {nameUser}
diff --git a/frontend/src/PagesAdmin/RecruitmentAdminPage/RecruitmentAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentAdminPage/RecruitmentAdminPage.tsx
index c3abb30f6..b5d8573b1 100644
--- a/frontend/src/PagesAdmin/RecruitmentAdminPage/RecruitmentAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentAdminPage/RecruitmentAdminPage.tsx
@@ -53,7 +53,7 @@ export function RecruitmentAdminPage() {
cells: [
{
content: {dbT(element, 'name')},
- value: ROUTES.frontend.recruitment,
+ value: element.id,
},
{
content: getObjectFieldOrNumber(element?.organization, 'name'),
@@ -78,7 +78,7 @@ export function RecruitmentAdminPage() {
navigate(
reverse({
pattern: ROUTES.frontend.organization_recruitment,
- urlParams: { recruitmentID: element.id },
+ urlParams: { recruitmentId: element.id },
}),
);
}}
diff --git a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.module.scss b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.module.scss
index 4e572e371..17caad5c7 100644
--- a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.module.scss
+++ b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.module.scss
@@ -11,15 +11,6 @@
}
}
-.infoContainer {
- padding: 0.2em;
- margin: 2em;
-}
-
-.textBottom {
- margin: 0.2em;
-}
-
.withdrawContainer {
display: flex;
flex-direction: row;
@@ -29,4 +20,3 @@
.withdrawnText {
color: $red-samf;
}
-
diff --git a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx
index 1aa3617eb..eeb2b5a7f 100644
--- a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentApplicantAdminPage.tsx
@@ -13,6 +13,7 @@ import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
import { dbT } from '~/utils';
+import { AdminPage } from '../AdminPageLayout';
import styles from './RecruitmentApplicantAdminPage.module.scss';
import { useMutation, useQuery } from '@tanstack/react-query';
import { RecruitmentInterviewNotesForm } from './RecruitmentInterviewNotesForm';
@@ -40,24 +41,15 @@ export function RecruitmentApplicantAdminPage() {
const otherRecruitmentApplication = data?.data.other_applications;
const interviewNotes = recruitmentApplication?.interview?.notes;
- const adminWithdraw = useMutation({
+ const adminWithdraw = useMutation({
mutationFn: (id: string) => {
return withdrawRecruitmentApplicationRecruiter(id);
},
onSuccess: () => {
- // TODO: make better response
toast.success(t(KEY.common_update_successful));
}
});
-
- function handleUpdateNotes(value: string) {
- const updatedNotes = value;
- if (recruitmentApplication?.id) {
- // TODO update notes
- }
- }
-
if (isLoading) {
return (
@@ -71,12 +63,9 @@ export function RecruitmentApplicantAdminPage() {
};
return (
-
+
-
- {applicant?.first_name} {applicant?.last_name}
-
)}
-
+
);
}
diff --git a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentInterviewNotesForm.tsx b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentInterviewNotesForm.tsx
index 8bdc86934..393d151c1 100644
--- a/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentInterviewNotesForm.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentApplicantAdminPage/RecruitmentInterviewNotesForm.tsx
@@ -28,6 +28,7 @@ export function RecruitmentInterviewNotesForm ({initialData }: RecruitmentInterv
});
function handleUpdateNotes(value: string) {
+ // TODO: Update notes using a put request
console.log(value);
}
diff --git a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx
index 82148d1d6..32432db3b 100644
--- a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx
@@ -4,8 +4,20 @@ import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useRouteLoaderData } from 'react-router-dom';
import { toast } from 'react-toastify';
-import { Button, Dropdown, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input } from '~/Components';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import {
+ Button,
+ Dropdown,
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ Input,
+ NumberInput,
+} from '~/Components';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
+import { FormDescription } from '~/Components/Forms/Form';
import { getOrganizations, postRecruitment, putRecruitment } from '~/api';
import type { OrganizationDto, RecruitmentDto } from '~/dto';
import { useTitle } from '~/hooks';
@@ -23,7 +35,7 @@ export function RecruitmentFormAdminPage() {
const data = useRouteLoaderData('recruitment') as RecruitmentLoader | undefined;
const { recruitmentId } = useParams();
- const [organizationOptions, setOrganizationOptions] = useState[]>([]);
+ const [organizationOptions, setOrganizationOptions] = useState[]>([]);
useEffect(() => {
getOrganizations().then((data) => {
@@ -47,6 +59,7 @@ export function RecruitmentFormAdminPage() {
utcTimestampToLocal(data?.recruitment?.reprioritization_deadline_for_groups, false) || '',
organization: getObjectFieldOrNumber(data?.recruitment?.organization, 'id') || 1,
max_applications: data?.recruitment?.max_applications,
+ promo_media: data?.recruitment?.promo_media || '',
};
const form = useForm({
@@ -97,7 +110,7 @@ export function RecruitmentFormAdminPage() {
{`${t(KEY.common_name)} ${t(KEY.common_norwegian)}`}
-
+
@@ -110,7 +123,7 @@ export function RecruitmentFormAdminPage() {
{`${t(KEY.common_name)} ${t(KEY.common_english)}`}
-
+
@@ -196,7 +209,7 @@ export function RecruitmentFormAdminPage() {
{t(KEY.max_applications)}
-
+
@@ -212,7 +225,7 @@ export function RecruitmentFormAdminPage() {
field.onChange(value)}
- initialValue={field.value}
+ value={field.value}
/>
@@ -220,6 +233,22 @@ export function RecruitmentFormAdminPage() {
)}
/>
+
+ (
+
+ {t(KEY.recruitment_promo_media)}
+ {t(KEY.promo_media_description)}
+
+
+
+
+
+ )}
+ />
+
{submitText}
diff --git a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/recruitmentSchema.ts b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/recruitmentSchema.ts
index c721d7d2e..7ae4146a1 100644
--- a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/recruitmentSchema.ts
+++ b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/recruitmentSchema.ts
@@ -14,7 +14,8 @@ export const recruitmentSchema = z
reprioritization_deadline_for_applicant: LOCAL_DATETIME,
reprioritization_deadline_for_groups: LOCAL_DATETIME,
organization: z.number().min(1, { message: 'Organization is required' }),
- max_applications: z.number().nullable(),
+ max_applications: z.number().min(0).max(99).optional(),
+ promo_media: z.string(),
})
.refine(
(data) => {
@@ -59,6 +60,17 @@ export const recruitmentSchema = z
message: i18next.t(KEY.error_recruitment_form_4),
path: ['reprioritization_deadline_for_groups'],
},
+ )
+ .refine(
+ (data) => {
+ const promoMedia = data.promo_media;
+ const regex = /^(https?:\/\/)?(www\.)?(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[?])|$))/;
+ return promoMedia.match(regex) || promoMedia.length === 11 || promoMedia === '';
+ },
+ {
+ message: i18next.t(KEY.promo_media_invalid),
+ path: ['promo_media'],
+ },
);
export type recruitmentFormType = z.infer;
diff --git a/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx
index 74c2d2d9c..936037ebd 100644
--- a/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentGangAdminPage/RecruitmentGangAdminPage.tsx
@@ -73,6 +73,7 @@ export function RecruitmentGangAdminPage() {
cells: [
{
content: {dbT(recruitmentPosition, 'name')},
+ value: dbT(recruitmentPosition, 'name'),
},
{
value: recruitmentPosition.is_funksjonaer_position,
@@ -97,7 +98,8 @@ export function RecruitmentGangAdminPage() {
reverse({
pattern: ROUTES.frontend.recruitment_application,
urlParams: {
- positionID: recruitmentPosition.id,
+ positionId: recruitmentPosition.id,
+ recruitmentId: recruitmentId,
},
}),
);
diff --git a/frontend/src/PagesAdmin/RecruitmentGangAllApplicantsAdminPage/RecruitmentGangAllApplicantsAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentGangAllApplicantsAdminPage/RecruitmentGangAllApplicantsAdminPage.tsx
index 4781ad16f..e35d79a0f 100644
--- a/frontend/src/PagesAdmin/RecruitmentGangAllApplicantsAdminPage/RecruitmentGangAllApplicantsAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentGangAllApplicantsAdminPage/RecruitmentGangAllApplicantsAdminPage.tsx
@@ -7,11 +7,12 @@ import { Table } from '~/Components/Table';
import { Text } from '~/Components/Text/Text';
import { downloadCSVGangRecruitment, getGang, getRecruitment, getRecruitmentApplicationsForGang } from '~/api';
import type { GangDto, RecruitmentApplicationDto, RecruitmentDto } from '~/dto';
-import { useCustomNavigate } from '~/hooks';
+import { useCustomNavigate, useTitle } from '~/hooks';
import { STATUS } from '~/http_status_codes';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
+import { RecruitmentPriorityChoicesMapping, RecruitmentStatusChoicesMapping } from '~/types';
import { dbT } from '~/utils';
import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout';
import styles from './RecruitmentGangAllApplicantsAdminPage.module.scss';
@@ -64,7 +65,6 @@ export function RecruitmentGangAllApplicantsAdminPage() {
setRecruitment(resp.data);
})
.catch((data) => {
- // TODO add error pop up message?
if (data.request.status === STATUS.HTTP_404_NOT_FOUND) {
navigate({ url: ROUTES.frontend.admin_recruitment });
}
@@ -81,6 +81,7 @@ export function RecruitmentGangAllApplicantsAdminPage() {
{ content: t(KEY.recruitment_position), 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 },
];
@@ -106,7 +107,12 @@ export function RecruitmentGangAllApplicantsAdminPage() {
{ content: {dbT(application.recruitment_position, 'name')} },
application.interview?.interview_time,
application.interview?.interview_location,
- application.recruiter_status,
+ application.recruiter_priority !== undefined
+ ? RecruitmentPriorityChoicesMapping[application.recruiter_priority]
+ : 'N/A',
+ application.recruiter_status !== undefined
+ ? RecruitmentStatusChoicesMapping[application.recruiter_status]
+ : 'N/A',
],
};
});
@@ -118,6 +124,7 @@ export function RecruitmentGangAllApplicantsAdminPage() {
};
const title = t(KEY.recruitment_all_applications);
+ useTitle(title);
const header = (
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/RecruitmentGangOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/RecruitmentGangOverviewPage.tsx
index 9dee4a5d3..f39c9c12f 100644
--- a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/RecruitmentGangOverviewPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/RecruitmentGangOverviewPage.tsx
@@ -1,4 +1,4 @@
-import { type ReactElement, useEffect, useState } from 'react';
+import { type ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { CrudButtons, Link, type Tab, TabView } from '~/Components';
@@ -11,7 +11,7 @@ import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
import { dbT, lowerCapitalize } from '~/utils';
import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout';
-import { AppletContainer } from './components';
+import { AppletContainer, RecruitmentInterviewGroupsList } from './components';
export function RecruitmentGangOverviewPage() {
const { recruitmentId } = useParams();
@@ -39,67 +39,21 @@ export function RecruitmentGangOverviewPage() {
});
}, [recruitmentId]);
- const tableGangColumns = [
- { content: t(KEY.common_gang), sortable: true },
- { content: t(KEY.recruitment_positions), sortable: true },
- ];
-
- // TODO: Only show gangs that user has access to, and only show gangs that are recruiting. ISSUE #1121
- const tableGangData = gangs.map((gang) => {
- const pageUrl = reverse({
- pattern: ROUTES.frontend.admin_recruitment_gang_position_overview,
- urlParams: { recruitmentId: recruitmentId, gangId: gang.id },
- });
-
- return {
- cells: [{ content: {dbT(gang, 'name')} }, gang.recruitment_positions],
- };
- });
-
- const tableSeparatePositionColumns = [
- { content: t(KEY.common_gang), sortable: true },
- { content: t(KEY.common_url), sortable: true },
- { content: t(KEY.common_administrate), sortable: false },
- ];
-
- async function deleteSeparatePositionHandler(separate_position: RecruitmentSeparatePositionDto) {
- if (separate_position.id && recruitmentId) {
- const msg = lowerCapitalize(`${t(KEY.form_confirm)} ${t(KEY.common_delete)}`);
- if (window.confirm(`${msg} ${dbT(separate_position, 'name')}`)) {
- deleteRecruitmentSeparatePosition(separate_position.id.toString()).then(() =>
- getRecruitment(recruitmentId).then((response) => {
- setRecruitment(response.data);
- }),
- );
+ const deleteSeparatePositionHandler = useCallback(
+ (separate_position: RecruitmentSeparatePositionDto) => {
+ if (separate_position.id && recruitmentId) {
+ const msg = lowerCapitalize(`${t(KEY.form_confirm)} ${t(KEY.common_delete)}`);
+ if (window.confirm(`${msg} ${dbT(separate_position, 'name')}`)) {
+ deleteRecruitmentSeparatePosition(separate_position.id.toString()).then(() =>
+ getRecruitment(recruitmentId).then((response) => {
+ setRecruitment(response.data);
+ }),
+ );
+ }
}
- }
- }
-
- const tableSeparatePositionData = recruitment?.separate_positions?.map((separate_position) => {
- const pageUrl = reverse({
- pattern: ROUTES.frontend.admin_recruitment_gang_separateposition_edit,
- urlParams: { recruitmentId: recruitmentId, separatePositionId: separate_position.id },
- });
-
- return {
- cells: [
- { content: {dbT(separate_position, 'name')} },
- { content: {separate_position.url} },
- {
- content: (
- {
- deleteSeparatePositionHandler(separate_position);
- }}
- onEdit={() => {
- navigate({ url: pageUrl });
- }}
- />
- ),
- },
- ],
- };
- });
+ },
+ [t, recruitmentId],
+ );
const backendUrl = ROUTES.backend.admin__samfundet_informationpage_changelist;
const header = (
@@ -108,14 +62,71 @@ export function RecruitmentGangOverviewPage() {
>
);
- const tabs: Tab[] = [
- { key: 1, label: t(KEY.common_gangs), value: },
- {
- key: 2,
- label: t(KEY.recruitment_gangs_with_separate_positions),
- value: ,
- },
- ];
+ const tabs: Tab[] = useMemo(() => {
+ const tableGangColumns = [
+ { content: t(KEY.common_gang), sortable: true },
+ { content: t(KEY.recruitment_positions), sortable: true },
+ ];
+
+ const tableGangData = gangs.map((gang) => {
+ const pageUrl = reverse({
+ pattern: ROUTES.frontend.admin_recruitment_gang_position_overview,
+ urlParams: { recruitmentId: recruitmentId, gangId: gang.id },
+ });
+
+ return {
+ cells: [
+ { content: {dbT(gang, 'name')}, value: dbT(gang, 'name') },
+ gang.recruitment_positions,
+ ],
+ };
+ });
+
+ const tableSeparatePositionColumns = [
+ { content: t(KEY.common_gang), sortable: true },
+ { content: t(KEY.common_url), sortable: true },
+ { content: t(KEY.common_administrate), sortable: false },
+ ];
+
+ const tableSeparatePositionData = recruitment?.separate_positions?.map((separate_position) => {
+ const pageUrl = reverse({
+ pattern: ROUTES.frontend.admin_recruitment_gang_separateposition_edit,
+ urlParams: { recruitmentId: recruitmentId, separatePositionId: separate_position.id },
+ });
+
+ return {
+ cells: [
+ {
+ content: {dbT(separate_position, 'name')},
+ value: dbT(separate_position, 'name'),
+ },
+ { content: {separate_position.url}, value: separate_position.url },
+ {
+ content: (
+ {
+ deleteSeparatePositionHandler(separate_position);
+ }}
+ onEdit={() => {
+ navigate({ url: pageUrl });
+ }}
+ />
+ ),
+ },
+ ],
+ };
+ });
+
+ return [
+ { key: 1, label: t(KEY.common_gangs), value: },
+ {
+ key: 2,
+ label: t(KEY.recruitment_gangs_with_separate_positions),
+ value: ,
+ },
+ { key: 3, label: t(KEY.recruitment_interview_groups), value: },
+ ];
+ }, [gangs, recruitment, t, recruitmentId, navigate, deleteSeparatePositionHandler]);
return (
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletCard/index.ts b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletCard/index.ts
deleted file mode 100644
index 35c68e37e..000000000
--- a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletCard/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { AppletCard } from './AppletCard';
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletContainer/AppletContainer.tsx b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletContainer/AppletContainer.tsx
index 0d55df0cd..cfb6fee0d 100644
--- a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletContainer/AppletContainer.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletContainer/AppletContainer.tsx
@@ -1,11 +1,11 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { H3 } from '~/Components';
-import { AppletCard } from '~/PagesAdmin/RecruitmentGangOverviewPage/components';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
import { lowerCapitalize } from '~/utils';
+import { AppletCard } from '../AppletCard/AppletCard';
import styles from './AppletContainer.module.scss';
type AppletCategory = {
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletContainer/index.ts b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletContainer/index.ts
deleted file mode 100644
index e6a387ecf..000000000
--- a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/AppletContainer/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { AppletContainer } from './AppletContainer';
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/RecruitmentInterviewGroupsList.module.scss b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/RecruitmentInterviewGroupsList.module.scss
new file mode 100644
index 000000000..c90b08831
--- /dev/null
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/RecruitmentInterviewGroupsList.module.scss
@@ -0,0 +1,18 @@
+@import "src/constants";
+
+@import "src/mixins";
+
+.container{
+ margin-top: 2rem;
+ @include flex-column-center;
+ gap:2rem;
+ padding-left: 2em;
+ padding-right: 2em;
+}
+
+.subContainer{
+ display: grid;
+ grid-template-columns: auto auto;
+ gap: 2rem;
+}
+
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/RecruitmentInterviewGroupsList.tsx b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/RecruitmentInterviewGroupsList.tsx
new file mode 100644
index 000000000..9f0ef8c96
--- /dev/null
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/RecruitmentInterviewGroupsList.tsx
@@ -0,0 +1,36 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom';
+import { toast } from 'react-toastify';
+import { getRecruitmentSharedInterviewGroups, getRecruitmentStats } from '~/api';
+import type { RecruitmentSharedInterviewGroupDto, RecruitmentStatsDto } from '~/dto';
+import { KEY } from '~/i18n/constants';
+import styles from './RecruitmentInterviewGroupsList.module.scss';
+import { RecruitmentInterviewGroupComponent } from './components/RecruitmentInterviewGroupComponent/RecruitmentInterviewGroupComponent';
+
+export function RecruitmentInterviewGroupsList() {
+ const { recruitmentId } = useParams();
+ const [interviewGroups, setInterviewGroups] = useState();
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ if (recruitmentId) {
+ getRecruitmentSharedInterviewGroups(recruitmentId)
+ .then((response) => {
+ setInterviewGroups(response.data);
+ })
+ .catch((error) => {
+ toast.error(t(KEY.common_something_went_wrong));
+ console.log(error);
+ });
+ }
+ }, [recruitmentId, t]);
+
+ return (
+
+ {interviewGroups?.map((interviewGroup: RecruitmentSharedInterviewGroupDto) => {
+ return ;
+ })}
+
+ );
+}
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/RecruitmentInterviewGroupComponent.module.scss b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/RecruitmentInterviewGroupComponent.module.scss
new file mode 100644
index 000000000..ebe1ecd19
--- /dev/null
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/RecruitmentInterviewGroupComponent.module.scss
@@ -0,0 +1,18 @@
+@import "src/constants";
+
+@import "src/mixins";
+
+.dropDownHeader {
+ background-color: $red_samf;
+ color: white;
+ font-size: 17px;
+ font-weight: 400;
+ @include theme-dark{
+ background-color: #590000;
+ }
+}
+
+.table {
+ width: 100%;
+ flex: 1;
+}
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/RecruitmentInterviewGroupComponent.tsx b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/RecruitmentInterviewGroupComponent.tsx
new file mode 100644
index 000000000..471c8260f
--- /dev/null
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/RecruitmentInterviewGroupComponent.tsx
@@ -0,0 +1,27 @@
+import { ExpandableHeader, Table } from '~/Components';
+import type { RecruitmentSharedInterviewGroupDto, RecruitmentStatsDto } from '~/dto';
+import { dbT } from '~/utils';
+import styles from './RecruitmentInterviewGroupComponent.module.scss';
+
+type RecruitmentInterviewGroupComponentProps = {
+ interviewGroup: RecruitmentSharedInterviewGroupDto;
+};
+
+export function RecruitmentInterviewGroupComponent({ interviewGroup }: RecruitmentInterviewGroupComponentProps) {
+ const interviewGroupHeader = dbT(interviewGroup, 'name') ?? 'N/A';
+ return (
+
+ {
+ return { cells: [dbT(position, 'name'), dbT(position.gang, 'name')] };
+ })}
+ />
+
+ );
+}
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/index.ts b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/index.ts
new file mode 100644
index 000000000..82bb123f8
--- /dev/null
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/RecruitmentInterviewGroupsList/components/RecruitmentInterviewGroupComponent/index.ts
@@ -0,0 +1 @@
+export { RecruitmentInterviewGroupComponent } from './RecruitmentInterviewGroupComponent';
diff --git a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/index.ts b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/index.ts
index 03c16335e..3e6c4124e 100644
--- a/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/index.ts
+++ b/frontend/src/PagesAdmin/RecruitmentGangOverviewPage/components/index.ts
@@ -1,2 +1,3 @@
-export { AppletCard } from './AppletCard';
-export { AppletContainer } from './AppletContainer';
+export { AppletCard } from './AppletCard/AppletCard';
+export { AppletContainer } from './AppletContainer/AppletContainer';
+export { RecruitmentInterviewGroupsList } from './RecruitmentInterviewGroupsList/RecruitmentInterviewGroupsList';
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 };
+ })}
+ />
+
},
{ key: 2, label: t(KEY.recruitment_statistics), value:
},
];
+ useTitle(t(KEY.recruitment_overview));
return (
diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx
new file mode 100644
index 000000000..481dbdfc1
--- /dev/null
+++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx
@@ -0,0 +1,304 @@
+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 { MultiSelect } from '~/Components/MultiSelect';
+import { postRecruitmentPosition, putRecruitmentPosition } from '~/api';
+import type { RecruitmentPositionDto, UserDto } from '~/dto';
+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,
+ interviewer_ids: z.array(z.number()).optional().nullable(),
+});
+
+type SchemaType = z.infer;
+
+interface FormProps {
+ initialData: Partial;
+ positionId?: string;
+ recruitmentId?: string;
+ gangId?: string;
+ users?: Partial;
+}
+
+export function RecruitmentPositionForm({ initialData, positionId, recruitmentId, gangId, users }: FormProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ 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 ?? '',
+ interviewer_ids: data.interviewer_ids || [],
+ };
+
+ 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,
+ interviewer_ids: initialData.interviewers?.map((interviewer) => interviewer.id) || [],
+ });
+ }, [initialData, form]);
+
+ // Convert users array to dropdown options
+ const interviewerOptions =
+ users
+ ?.filter((user) => user?.id && (user?.username || user?.first_name))
+ .map((user) => ({
+ value: user?.id,
+ label: user?.username || `${user?.first_name} ${user?.last_name}`,
+ })) || [];
+
+ // Get currently selected interviewers
+ const selectedInterviewers = form.watch('interviewer_ids') || [];
+
+ return (
+
+
+ );
+}
diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.module.scss b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.module.scss
index 8c9795666..be7eab6a4 100644
--- a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.module.scss
+++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.module.scss
@@ -24,3 +24,8 @@
justify-content: center;
margin-top: 20%;
}
+
+.item {
+ flex-grow: 1;
+ max-width: 50%;
+}
diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx
index bee0c85a6..0536248ec 100644
--- a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx
@@ -2,235 +2,74 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
-import { SamfundetLogoSpinner } from '~/Components';
-import { SamfForm } from '~/Forms/SamfForm';
-import { SamfFormField } from '~/Forms/SamfFormField';
-import { getRecruitmentPosition, postRecruitmentPosition, putRecruitmentPosition } from '~/api';
-import type { RecruitmentPositionDto } from '~/dto';
-import { STATUS } from '~/http_status_codes';
+import { getRecruitmentPosition, getUsers } from '~/api';
+import type { RecruitmentPositionDto, UserDto } from '~/dto';
+import { useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout';
-import styles from './RecruitmentPositionFormAdminPage.module.scss';
-
-type FormType = {
- name_nb: string;
- name_en: string;
-
- norwegian_applicants_only: boolean;
-
- short_description_nb: string;
- short_description_en: string;
-
- long_description_nb: string;
- long_description_en: string;
-
- is_funksjonaer_position: boolean;
-
- default_application_letter_nb: string;
- default_application_letter_en: string;
-
- tags: string;
-};
+import { RecruitmentPositionForm } from './RecruitmentPositionForm';
export function RecruitmentPositionFormAdminPage() {
const { t } = useTranslation();
const navigate = useNavigate();
-
- // Form data
const { recruitmentId, gangId, positionId } = useParams();
- const [showSpinner, setShowSpinner] = useState(true);
const [position, setPosition] = useState>();
- const [norwegianApplicantsOnly, setNorwegianApplicantsOnly] = useState(false);
+ const [users, setUsers] = useState>();
- // Fetch data if edit mode.
- // biome-ignore lint/correctness/useExhaustiveDependencies: t and navigate do not need to be in deplist
useEffect(() => {
if (positionId) {
getRecruitmentPosition(positionId)
.then((data) => {
setPosition(data.data);
- setShowSpinner(false);
})
- .catch((data) => {
- if (data.request.status === STATUS.HTTP_404_NOT_FOUND) {
- navigate(
- reverse({
- pattern: ROUTES.frontend.admin_recruitment_gang_position_overview,
- urlParams: { recruitmentId: recruitmentId, gangId: gangId },
- }),
- { replace: true },
- );
- }
+ .catch(() => {
toast.error(t(KEY.common_something_went_wrong));
+ navigate(
+ reverse({
+ pattern: ROUTES.frontend.admin_recruitment_gang_position_overview,
+ urlParams: { recruitmentId, gangId },
+ }),
+ { replace: true },
+ );
});
- } else {
- setShowSpinner(false);
}
- }, [positionId]);
+ getUsers().then((data) => {
+ setUsers(data);
+ });
+ }, [positionId, recruitmentId, gangId, navigate, t]);
const initialData: Partial = {
- name_nb: position?.name_nb,
- name_en: position?.name_en,
-
- short_description_nb: position?.short_description_nb,
- short_description_en: position?.short_description_en,
-
- long_description_nb: position?.long_description_nb,
- long_description_en: position?.long_description_en,
-
+ name_nb: position?.name_nb || '',
+ name_en: position?.name_en || '',
norwegian_applicants_only: position?.norwegian_applicants_only || false,
-
- default_application_letter_nb: position?.default_application_letter_nb,
- default_application_letter_en: position?.default_application_letter_en,
+ short_description_nb: position?.short_description_nb || '',
+ short_description_en: position?.short_description_en || '',
+ long_description_nb: position?.long_description_nb || '',
+ long_description_en: position?.long_description_en || '',
is_funksjonaer_position: position?.is_funksjonaer_position || false,
-
- tags: position?.tags,
- // TODO: Add necessary fields to form.
- // gang: gangId,
- // recruitment: recruitmentId,
- // interviewers: position?.interviewers,
+ default_application_letter_nb: position?.default_application_letter_nb || '',
+ default_application_letter_en: position?.default_application_letter_en || '',
+ tags: position?.tags || '',
+ interviewers: position?.interviewers || [],
};
- const submitText = positionId ? t(KEY.common_save) : t(KEY.common_create);
-
- // Loading.
- if (showSpinner) {
- return (
-
-
-
- );
- }
+ const title = positionId
+ ? `${t(KEY.common_edit)} ${position?.name_nb}`
+ : `${t(KEY.common_create)} ${t(KEY.recruitment_position)}`;
- function handleOnSubmit(data: RecruitmentPositionDto) {
- const updatedPosition = data;
- updatedPosition.gang.id = Number.parseInt(gangId ?? '');
- updatedPosition.recruitment = recruitmentId ?? '';
- updatedPosition.interviewers = [];
- if (positionId) {
- // Update page.
+ useTitle(title);
- putRecruitmentPosition(positionId, updatedPosition)
- .then(() => {
- toast.success(t(KEY.common_update_successful));
- navigate(
- reverse({
- pattern: ROUTES.frontend.admin_recruitment_gang_position_overview,
- urlParams: { recruitmentId: recruitmentId, gangId: gangId },
- }),
- );
- })
- .catch((error) => {
- toast.error(t(KEY.common_something_went_wrong));
- console.error(error);
- });
- } else {
- // Post new page.
- postRecruitmentPosition(updatedPosition)
- .then(() => {
- navigate(
- reverse({
- pattern: ROUTES.frontend.admin_recruitment_gang_position_overview,
- urlParams: { recruitmentId: recruitmentId, gangId: gangId },
- }),
- );
- toast.success(t(KEY.common_creation_successful));
- })
- .catch((error) => {
- toast.error(t(KEY.common_something_went_wrong));
- console.error(error);
- });
- }
- }
return (
- <>
-
-
-
onSubmit={handleOnSubmit} initialData={initialData} submitText={submitText}>
-
-
- field="norwegian_applicants_only"
- type="checkbox"
- label={`${t(KEY.recruitment_norwegian_applicants_only)}?`}
- onChange={() => {
- setNorwegianApplicantsOnly(!norwegianApplicantsOnly);
- }}
- required={true}
- />
-
-
-
- field="name_nb"
- type="text"
- label={`${t(KEY.common_name)} ${t(KEY.common_norwegian)}`}
- required={true}
- />
-
- field="name_en"
- type="text"
- required={!norwegianApplicantsOnly}
- label={`${t(KEY.common_name)} ${t(KEY.common_english)}`}
- />
-
-
-
-
- field="short_description_nb"
- type="text"
- label={`${t(KEY.common_short_description)} ${t(KEY.common_norwegian)}`}
- required={true}
- />
-
- field="short_description_en"
- required={!norwegianApplicantsOnly}
- type="text"
- label={`${t(KEY.common_short_description)} ${t(KEY.common_english)}`}
- />
-
-
-
- field="long_description_nb"
- type="text_long"
- label={`${t(KEY.common_long_description)} ${t(KEY.common_norwegian)}`}
- required={true}
- />
-
- field="long_description_en"
- type="text_long"
- required={!norwegianApplicantsOnly}
- label={`${t(KEY.common_long_description)} ${t(KEY.common_english)}`}
- />
-
-
-
- field="is_funksjonaer_position"
- type="checkbox"
- label={`${t(KEY.recruitment_funksjonaer)}?`}
- required={true}
- />
-
-
-
- field="default_application_letter_nb"
- type="text_long"
- label={`${t(KEY.recrutment_default_application_letter)} ${t(KEY.common_norwegian)}`}
- required={true}
- />
-
- field="default_application_letter_en"
- type="text_long"
- label={`${t(KEY.recrutment_default_application_letter)} ${t(KEY.common_english)}`}
- required={!norwegianApplicantsOnly}
- />
-
-
- field="tags" type="text" label={t(KEY.common_tags) ?? ''} />
-
-
-
-
- >
+
+
+
);
}
diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx
index a1d57ad34..9d90f823c 100644
--- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
@@ -23,17 +23,74 @@ export function RecruitmentPositionOverviewPage() {
const [withdrawnApplicants, setWithdrawnApplicants] = useState([]);
const [rejectedApplicants, setRejectedApplicants] = useState([]);
const [acceptedApplicants, setAcceptedApplicants] = useState([]);
+ const [hardtogetApplicants, setHardtogetApplicants] = useState([]); //Applicants that have been offered a position, but did not accept it
const [recruiterStatuses, setRecruiterStatuses] = useState<[][]>([]);
const [showSpinner, setShowSpinner] = useState(true);
const { t } = useTranslation();
+ const load = useCallback(() => {
+ if (!recruitmentId || !gangId || !positionId) {
+ return;
+ }
+ getRecruitmentApplicationsForGang(gangId, recruitmentId)
+ .then((data) => {
+ setRecruitmentApplicants(
+ data.data.filter(
+ (recruitmentApplicant) =>
+ !recruitmentApplicant.withdrawn &&
+ recruitmentApplicant.recruiter_status === 0 &&
+ recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
+ ),
+ );
+ setWithdrawnApplicants(
+ data.data.filter(
+ (recruitmentApplicant) =>
+ recruitmentApplicant.withdrawn &&
+ recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
+ ),
+ );
+ setHardtogetApplicants(
+ data.data.filter(
+ (recruitmentApplicant) =>
+ !recruitmentApplicant.withdrawn &&
+ recruitmentApplicant.recruiter_status === 2 &&
+ recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
+ ),
+ );
+ setRejectedApplicants(
+ data.data.filter(
+ (recruitmentApplicant) =>
+ !recruitmentApplicant.withdrawn &&
+ recruitmentApplicant.recruiter_status === 3 &&
+ recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
+ ),
+ );
+ setAcceptedApplicants(
+ data.data.filter(
+ (recruitmentApplicant) =>
+ !recruitmentApplicant.withdrawn &&
+ recruitmentApplicant.recruiter_status === 1 &&
+ recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
+ ),
+ );
+ setShowSpinner(false);
+ })
+ .catch((data) => {
+ if (data.status === STATUS.HTTP_404_NOT_FOUND) {
+ navigate(ROUTES.frontend.not_found, { replace: true });
+ }
+ toast.error(t(KEY.common_something_went_wrong));
+ });
+ }, [recruitmentId, gangId, positionId, navigate, t]);
+
useEffect(() => {
- // getRecruitmentApplicationStateChoices
- recruitmentId &&
- gangId &&
- positionId &&
- getRecruitmentApplicationsForGang(gangId, recruitmentId)
+ load();
+ }, [load]);
+
+ const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => {
+ positionId &&
+ updateRecruitmentApplicationStateForPosition(id, data)
.then((data) => {
setRecruitmentApplicants(
data.data.filter(
@@ -50,48 +107,11 @@ export function RecruitmentPositionOverviewPage() {
recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
),
);
- setRejectedApplicants(
+ setHardtogetApplicants(
data.data.filter(
(recruitmentApplicant) =>
!recruitmentApplicant.withdrawn &&
- (recruitmentApplicant.recruiter_status === 2 || recruitmentApplicant.recruiter_status === 3) &&
- recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
- ),
- );
- setAcceptedApplicants(
- data.data.filter(
- (recruitmentApplicant) =>
- !recruitmentApplicant.withdrawn &&
- recruitmentApplicant.recruiter_status === 1 &&
- recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
- ),
- );
- setShowSpinner(false);
- })
- .catch((data) => {
- if (data.status === STATUS.HTTP_404_NOT_FOUND) {
- navigate(ROUTES.frontend.not_found, { replace: true });
- }
- toast.error(t(KEY.common_something_went_wrong));
- });
- }, [recruitmentId, gangId, positionId, navigate, t]);
-
- const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => {
- positionId &&
- updateRecruitmentApplicationStateForPosition(id, data)
- .then((data) => {
- setRecruitmentApplicants(
- data.data.filter(
- (recruitmentApplicant) =>
- !recruitmentApplicant.withdrawn &&
- recruitmentApplicant.recruiter_status === 0 &&
- recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
- ),
- );
- setWithdrawnApplicants(
- data.data.filter(
- (recruitmentApplicant) =>
- recruitmentApplicant.withdrawn &&
+ recruitmentApplicant.recruiter_status === 2 &&
recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
),
);
@@ -99,7 +119,7 @@ export function RecruitmentPositionOverviewPage() {
data.data.filter(
(recruitmentApplicant) =>
!recruitmentApplicant.withdrawn &&
- (recruitmentApplicant.recruiter_status === 2 || recruitmentApplicant.recruiter_status === 3) &&
+ recruitmentApplicant.recruiter_status === 3 &&
recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId),
),
);
@@ -156,6 +176,7 @@ export function RecruitmentPositionOverviewPage() {
gangId={gangId}
positionId={positionId}
updateStateFunction={updateApplicationState}
+ onInterviewChange={load}
/>
@@ -185,6 +206,23 @@ export function RecruitmentPositionOverviewPage() {
)}
+
+
+ {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..9d75ec8a9 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[];
@@ -27,47 +27,52 @@ export function ProcessedApplicants({ data, type, revertStateFunction }: Process
revertStateFunction && { content: '', sortable: false },
];
- const rows = data.map((application) => ({
- cells: [
- {
- content: (
-
- {`${application.user.first_name} ${application.user.last_name}`}
-
- ),
- },
- { content: application.user?.phone_number, value: application.user?.phone_number },
- { content: application.user?.email, value: application.user?.email },
- { content: application.interview?.interview_time, value: application.interview?.interview_time },
- { content: application.interview?.interview_location, value: application.interview?.interview_location },
- { content: application.recruiter_status, value: application.recruiter_status },
- revertStateFunction && {
- content: (
- revertStateFunction(application.id, { recruiter_status: 0 })}
- >
- {t(KEY.recruitment_revert_status)}
-
- ),
- value: application.recruiter_status,
- },
- ],
- }));
+ const rows = data.map((application) => {
+ const applicantName = `${application.user.first_name} ${application.user.last_name}`;
+ return {
+ cells: [
+ {
+ content: (
+
+ {applicantName}
+
+ ),
+ value: applicantName,
+ },
+ { content: application.user?.phone_number, value: application.user?.phone_number },
+ { content: application.user?.email, value: application.user?.email },
+ { content: application.interview?.interview_time, value: application.interview?.interview_time },
+ { content: application.interview?.interview_location, value: application.interview?.interview_location },
+ { content: application.recruiter_status, value: application.recruiter_status },
+ revertStateFunction && {
+ content: (
+ revertStateFunction(application.id, { recruiter_status: 0 })}
+ >
+ {t(KEY.recruitment_revert_status)}
+
+ ),
+ value: application.recruiter_status,
+ },
+ ],
+ };
+ });
const styleType = {
withdrawn: styles.withdrawn,
accepted: styles.accepted,
rejected: styles.rejected,
+ hardtoget: styles.hardtoget,
};
return (
diff --git a/frontend/src/PagesAdmin/RecruitmentRecruiterDashboardPage/RecruitmentRecruiterDashboardPage.tsx b/frontend/src/PagesAdmin/RecruitmentRecruiterDashboardPage/RecruitmentRecruiterDashboardPage.tsx
index 54cd17480..a1e5f76d1 100644
--- a/frontend/src/PagesAdmin/RecruitmentRecruiterDashboardPage/RecruitmentRecruiterDashboardPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentRecruiterDashboardPage/RecruitmentRecruiterDashboardPage.tsx
@@ -7,7 +7,7 @@ import { Table } from '~/Components/Table';
import { Text } from '~/Components/Text/Text';
import { getRecruitmentRecruiterDashboard } from '~/api';
import type { RecruitmentApplicationDto, RecruitmentDto } from '~/dto';
-import { useCustomNavigate } from '~/hooks';
+import { useCustomNavigate, useTitle } from '~/hooks';
import { STATUS } from '~/http_status_codes';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
@@ -23,6 +23,7 @@ export function RecruitmentRecruiterDashboardPage() {
const [recruitment, setRecruitment] = useState();
const [applications, setApplications] = useState();
const [loading, setLoading] = useState(true);
+ useTitle(`${t(KEY.recruitment_recruiter_dashboard)} ${dbT(recruitment, 'name')}`);
// biome-ignore lint/correctness/useExhaustiveDependencies: navigate must not be in deplist
useEffect(() => {
@@ -91,7 +92,8 @@ export function RecruitmentRecruiterDashboardPage() {
url={reverse({
pattern: ROUTES.frontend.recruitment_application,
urlParams: {
- positionID: application.recruitment_position.id,
+ positionId: application.recruitment_position.id,
+ recruitmentId: recruitmentId,
},
})}
>
diff --git a/frontend/src/PagesAdmin/RecruitmentSeparatePositionFormAdminPage/RecruitmentSeparatePositionFormAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentSeparatePositionFormAdminPage/RecruitmentSeparatePositionFormAdminPage.tsx
index c113d44ed..eead6cb0f 100644
--- a/frontend/src/PagesAdmin/RecruitmentSeparatePositionFormAdminPage/RecruitmentSeparatePositionFormAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentSeparatePositionFormAdminPage/RecruitmentSeparatePositionFormAdminPage.tsx
@@ -5,6 +5,7 @@ import { SamfForm } from '~/Forms/SamfForm';
import { SamfFormField } from '~/Forms/SamfFormField';
import { postRecruitmentSeparatePosition, putRecruitmentSeparatePosition } from '~/api';
import type { RecruitmentSeparatePositionDto } from '~/dto';
+import { useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import type { SeparatePositionLoader } from '~/router/loaders';
@@ -25,6 +26,8 @@ type FormType = {
export function RecruitmentSeparatePositionFormAdminPage() {
const { t } = useTranslation();
const navigate = useNavigate();
+ const title = `${t(KEY.common_create)} ${t(KEY.recruitment_position)}`;
+ useTitle(title);
// Form data
const { recruitmentId, separatePositionId } = useParams();
@@ -80,7 +83,7 @@ export function RecruitmentSeparatePositionFormAdminPage() {
}
return (
<>
-
+
onSubmit={handleOnSubmit} initialData={initialData} submitText={submitText}>
diff --git a/frontend/src/PagesAdmin/RecruitmentUsersWithoutInterviewGangPage/RecruitmentUsersWithoutInterviewGangPage.tsx b/frontend/src/PagesAdmin/RecruitmentUsersWithoutInterviewGangPage/RecruitmentUsersWithoutInterviewGangPage.tsx
index e104622ed..3098a460e 100644
--- a/frontend/src/PagesAdmin/RecruitmentUsersWithoutInterviewGangPage/RecruitmentUsersWithoutInterviewGangPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentUsersWithoutInterviewGangPage/RecruitmentUsersWithoutInterviewGangPage.tsx
@@ -6,7 +6,7 @@ import { RecruitmentWithoutInterviewTable } from '~/Components';
import { Text } from '~/Components/Text/Text';
import { getApplicantsWithoutInterviews, getGang, getRecruitment } from '~/api';
import type { GangDto, RecruitmentDto, RecruitmentUserDto } from '~/dto';
-import { useCustomNavigate } from '~/hooks';
+import { useCustomNavigate, useTitle } from '~/hooks';
import { STATUS } from '~/http_status_codes';
import { KEY } from '~/i18n/constants';
import { ROUTES } from '~/routes';
@@ -73,6 +73,7 @@ export function RecruitmentUsersWithoutInterviewGangPage() {
}, [recruitmentId]);
const title = t(KEY.recruitment_applicants_without_interview);
+ useTitle(title);
const header = (
diff --git a/frontend/src/PagesAdmin/RecruitmentUsersWithoutThreeInterviewCriteriaPage/RecruitmentUsersWithoutThreeInterviewCriteriaPage.tsx b/frontend/src/PagesAdmin/RecruitmentUsersWithoutThreeInterviewCriteriaPage/RecruitmentUsersWithoutThreeInterviewCriteriaPage.tsx
index aba5d48fc..d0784d1c0 100644
--- a/frontend/src/PagesAdmin/RecruitmentUsersWithoutThreeInterviewCriteriaPage/RecruitmentUsersWithoutThreeInterviewCriteriaPage.tsx
+++ b/frontend/src/PagesAdmin/RecruitmentUsersWithoutThreeInterviewCriteriaPage/RecruitmentUsersWithoutThreeInterviewCriteriaPage.tsx
@@ -6,7 +6,7 @@ import { RecruitmentWithoutInterviewTable } from '~/Components';
import { Text } from '~/Components/Text/Text';
import { getApplicantsWithoutThreeInterviewCriteria, getRecruitment } from '~/api';
import type { RecruitmentDto, RecruitmentUserDto } from '~/dto';
-import { useCustomNavigate } from '~/hooks';
+import { useCustomNavigate, useTitle } from '~/hooks';
import { STATUS } from '~/http_status_codes';
import { KEY } from '~/i18n/constants';
import { ROUTES } from '~/routes';
@@ -21,6 +21,7 @@ export function RecruitmentUsersWithoutThreeInterviewCriteriaPage() {
const [showSpinner, setShowSpinner] = useState(true);
const { t } = useTranslation();
const navigate = useCustomNavigate();
+ useTitle(t(KEY.recruitment_three_interviews_criteria_button));
// biome-ignore lint/correctness/useExhaustiveDependencies: t does not need to be in deplist
useEffect(() => {
diff --git a/frontend/src/PagesAdmin/RoleAdminPage/RoleAdminPage.tsx b/frontend/src/PagesAdmin/RoleAdminPage/RoleAdminPage.tsx
index 13f9dbb3e..c57ce479a 100644
--- a/frontend/src/PagesAdmin/RoleAdminPage/RoleAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RoleAdminPage/RoleAdminPage.tsx
@@ -1,12 +1,15 @@
import { useTranslation } from 'react-i18next';
import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
+import { useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
export function RoleAdminPage() {
const { t } = useTranslation();
+ const title = t(KEY.common_role);
+ useTitle(title);
return (
-
+
);
diff --git a/frontend/src/PagesAdmin/RoleFormAdminPage/RoleFormAdminPage.tsx b/frontend/src/PagesAdmin/RoleFormAdminPage/RoleFormAdminPage.tsx
new file mode 100644
index 000000000..ff79ed8d4
--- /dev/null
+++ b/frontend/src/PagesAdmin/RoleFormAdminPage/RoleFormAdminPage.tsx
@@ -0,0 +1,19 @@
+import { useTranslation } from 'react-i18next';
+import { useRouteLoaderData } from 'react-router-dom';
+import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
+import { RoleForm } from '~/PagesAdmin/RoleFormAdminPage/components';
+import { KEY } from '~/i18n/constants';
+import type { RoleLoader } from '~/router/loaders';
+import { lowerCapitalize } from '~/utils';
+
+export function RoleFormAdminPage() {
+ const { t } = useTranslation();
+ const data = useRouteLoaderData('role') as RoleLoader | undefined;
+
+ const title = lowerCapitalize(`${t(data?.role ? KEY.common_edit : KEY.common_create)} ${t(KEY.common_role)}`);
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/RoleForm.module.scss b/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/RoleForm.module.scss
new file mode 100644
index 000000000..e83bbd550
--- /dev/null
+++ b/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/RoleForm.module.scss
@@ -0,0 +1,5 @@
+.action_row {
+ display: flex;
+ justify-content: flex-end;
+ margin: 1rem 0;
+}
diff --git a/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/RoleForm.tsx b/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/RoleForm.tsx
new file mode 100644
index 000000000..d1de3c3c3
--- /dev/null
+++ b/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/RoleForm.tsx
@@ -0,0 +1,176 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'react-toastify';
+import { z } from 'zod';
+import {
+ Alert,
+ Button,
+ Dropdown,
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ Input,
+} from '~/Components';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
+import { MultiSelect } from '~/Components/MultiSelect';
+import { createRole, editRole, getPermissions } from '~/api';
+import type { RoleDto } from '~/dto';
+import { KEY } from '~/i18n/constants';
+import { permissionKeys } from '~/queryKeys';
+import { ROLE_CONTENT_TYPE, ROLE_NAME } from '~/schema/role';
+import styles from './RoleForm.module.scss';
+
+const schema = z.object({
+ name: ROLE_NAME,
+ permissions: z.array(z.number()),
+ content_type: ROLE_CONTENT_TYPE.nullish(),
+});
+
+type SchemaType = z.infer;
+
+type ContentTypeSchemaType = z.infer;
+
+type Props = {
+ role?: RoleDto;
+};
+
+export function RoleForm({ role }: Props) {
+ const { t } = useTranslation();
+
+ const {
+ data: allPermissions,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: permissionKeys.all,
+ queryFn: getPermissions,
+ });
+
+ const edit = useMutation({
+ mutationFn: editRole,
+ onSuccess: () => {
+ toast.success(t(KEY.common_save_successful));
+ },
+ });
+
+ const create = useMutation({
+ mutationFn: createRole,
+ onSuccess: () => {
+ toast.success(t(KEY.common_creation_successful));
+ },
+ });
+
+ const isPending = edit.isPending || create.isPending;
+
+ const permissionOptions = useMemo[]>(() => {
+ if (!allPermissions) {
+ return [];
+ }
+ return allPermissions.map((p) => ({
+ value: p.id,
+ label: p.name,
+ }));
+ }, [allPermissions]);
+
+ const selectedPermissions = useMemo[]>(() => {
+ if (!allPermissions || !role) {
+ return [];
+ }
+ const permissions = allPermissions.filter((p) => role.permissions.includes(p.id));
+ return permissions.map((p) => ({
+ value: p.id,
+ label: p.name,
+ }));
+ }, [role, allPermissions]);
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: role?.name ?? '',
+ permissions: role?.permissions ?? [],
+ content_type: (role?.content_type ?? '') as ContentTypeSchemaType,
+ },
+ });
+
+ function onSubmit(values: SchemaType) {
+ console.log(values);
+ if (role) {
+ edit.mutate({ id: role.id, ...values });
+ } else {
+ create.mutate(values);
+ }
+ }
+
+ const contentTypeLabels: Record = {
+ '': t(KEY.common_any),
+ organization: t(KEY.recruitment_organization),
+ gang: t(KEY.common_gang),
+ section: t(KEY.common_section),
+ };
+
+ const contentTypeOptions: DropdownOption[] = ROLE_CONTENT_TYPE.options.map((ct) => ({
+ value: ct,
+ label: contentTypeLabels[ct],
+ }));
+
+ return (
+
+
+ (
+
+ {t(KEY.common_name)}
+
+
+
+
+
+ )}
+ />
+ (
+
+ {t(KEY.role_content_type)}
+
+
+
+
+
+ )}
+ />
+ (
+
+ {t(KEY.common_permissions)}
+
+ {isLoading ? (
+ {t(KEY.common_loading)}...
+ ) : isError ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+ />
+
+
+
+ {t(KEY.common_save)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/index.ts b/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/index.ts
new file mode 100644
index 000000000..f4563907a
--- /dev/null
+++ b/frontend/src/PagesAdmin/RoleFormAdminPage/components/RoleForm/index.ts
@@ -0,0 +1 @@
+export { RoleForm } from './RoleForm';
diff --git a/frontend/src/PagesAdmin/RoleFormAdminPage/components/index.ts b/frontend/src/PagesAdmin/RoleFormAdminPage/components/index.ts
new file mode 100644
index 000000000..f4563907a
--- /dev/null
+++ b/frontend/src/PagesAdmin/RoleFormAdminPage/components/index.ts
@@ -0,0 +1 @@
+export { RoleForm } from './RoleForm';
diff --git a/frontend/src/PagesAdmin/RoleFormAdminPage/index.ts b/frontend/src/PagesAdmin/RoleFormAdminPage/index.ts
new file mode 100644
index 000000000..e9fe47865
--- /dev/null
+++ b/frontend/src/PagesAdmin/RoleFormAdminPage/index.ts
@@ -0,0 +1 @@
+export { RoleFormAdminPage } from './RoleFormAdminPage';
diff --git a/frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx b/frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx
index 1c481a275..f1b22de49 100644
--- a/frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RolesAdminPage/RolesAdminPage.tsx
@@ -1,31 +1,28 @@
-import { useMemo, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
-import { CrudButtons, Link } from '~/Components';
+import { Button, CrudButtons, Link } from '~/Components';
import { Table } from '~/Components/Table';
import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
-import type { RoleDto } from '~/dto';
+import { getRoles } from '~/api';
+import { useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
+import { reverse } from '~/named-urls';
+import { roleKeys } from '~/queryKeys';
+import { ROUTES } from '~/routes';
import { lowerCapitalize } from '~/utils';
export function RolesAdminPage() {
const { t } = useTranslation();
-
const navigate = useNavigate();
+ const title = t(KEY.common_roles);
+ useTitle(title);
- const [roles, setRoles] = useState([
- {
- id: 1,
- name: 'Opptaksansvarlig',
- permissions: ['samfundet.test_permission', 'samfundet.user_create'],
- },
- {
- id: 2,
- name: 'Intervjuer',
- permissions: [],
- },
- ]);
- const [loading, setLoading] = useState(false);
+ const { data: roles, isLoading } = useQuery({
+ queryKey: roleKeys.all,
+ queryFn: getRoles,
+ });
const columns = [
{ content: t(KEY.common_name), sortable: true },
@@ -53,15 +50,32 @@ export function RolesAdminPage() {
value: 0,
},
{
- content: navigate('#')} />,
+ content: (
+
+ navigate(
+ reverse({
+ pattern: ROUTES.frontend.admin_roles_edit,
+ urlParams: { roleId: r.id },
+ }),
+ )
+ }
+ />
+ ),
},
],
};
});
}, [roles]);
+ const header = (
+
+ {lowerCapitalize(`${t(KEY.common_create)} ${t(KEY.common_role)}`)}
+
+ );
+
return (
-
+
);
diff --git a/frontend/src/PagesAdmin/RoomAdminPage/CreateInterviewRoomPage/CreateInterviewRoomPage.tsx b/frontend/src/PagesAdmin/RoomAdminPage/CreateInterviewRoomPage/CreateInterviewRoomPage.tsx
index f2c90feda..d6d6c2a41 100644
--- a/frontend/src/PagesAdmin/RoomAdminPage/CreateInterviewRoomPage/CreateInterviewRoomPage.tsx
+++ b/frontend/src/PagesAdmin/RoomAdminPage/CreateInterviewRoomPage/CreateInterviewRoomPage.tsx
@@ -12,6 +12,7 @@ import { STATUS } from '~/http_status_codes';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import { ROUTES } from '~/routes';
+import { utcTimestampToLocal } from '~/utils';
type FormType = {
name: string;
@@ -55,8 +56,8 @@ export function CreateInterviewRoomPage() {
const initialData: Partial = {
name: room?.name,
location: room?.location,
- start_time: room?.start_time,
- end_time: room?.end_time,
+ start_time: utcTimestampToLocal(room?.start_time, false) || '',
+ end_time: utcTimestampToLocal(room?.end_time, false) || '',
};
const submitText = roomId ? t(KEY.common_save) : t(KEY.common_create);
diff --git a/frontend/src/PagesAdmin/RoomAdminPage/RoomAdminPage.tsx b/frontend/src/PagesAdmin/RoomAdminPage/RoomAdminPage.tsx
index 4a9c71036..058016f03 100644
--- a/frontend/src/PagesAdmin/RoomAdminPage/RoomAdminPage.tsx
+++ b/frontend/src/PagesAdmin/RoomAdminPage/RoomAdminPage.tsx
@@ -3,9 +3,10 @@ import { useTranslation } from 'react-i18next';
import { useRouteLoaderData } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Button, CrudButtons, Table } from '~/Components';
+import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
import { deleteInterviewRoom, getInterviewRoomsForRecruitment } from '~/api';
import type { InterviewRoomDto } from '~/dto';
-import { useCustomNavigate } from '~/hooks';
+import { useCustomNavigate, useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import type { RecruitmentLoader } from '~/router/loaders';
@@ -16,6 +17,7 @@ export function RoomAdminPage() {
const data = useRouteLoaderData('recruitment') as RecruitmentLoader | undefined;
const navigate = useCustomNavigate();
const { t } = useTranslation();
+ useTitle(`${t(KEY.common_room)} ${t(KEY.common_overview)}`);
useEffect(() => {
if (data?.recruitment?.id) {
@@ -73,16 +75,21 @@ export function RoomAdminPage() {
return (
<>
-
- {t(KEY.common_create)}
-
-
+
+ {t(KEY.common_create)}
+
+
+
>
);
}
diff --git a/frontend/src/PagesAdmin/SaksdokumentFormAdminPage/SaksdokumentFormAdminPage.tsx b/frontend/src/PagesAdmin/SaksdokumentFormAdminPage/SaksdokumentFormAdminPage.tsx
index 81559d540..bbf3f12d5 100644
--- a/frontend/src/PagesAdmin/SaksdokumentFormAdminPage/SaksdokumentFormAdminPage.tsx
+++ b/frontend/src/PagesAdmin/SaksdokumentFormAdminPage/SaksdokumentFormAdminPage.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { SamfForm } from '~/Forms/SamfForm';
import { SamfFormField } from '~/Forms/SamfFormField';
import { getSaksdokument, postSaksdokument, putSaksdokument } from '~/api';
@@ -56,13 +56,13 @@ export function SaksdokumentFormAdminPage() {
};
// TODO get categories from API (this will not work).
- const categoryOptions: DropDownOption[] = [
+ const categoryOptions: DropdownOption[] = [
{ value: 'FS_REFERAT', label: 'FS_REFERAT' },
{ value: 'ARSBERETNINGER', label: 'ARSBERETNINGER' },
{ value: 'STYRET', label: 'STYRET' },
{ value: 'RADET', label: 'RADET' },
];
- const defaultCategoryOption: DropDownOption | undefined =
+ const defaultCategoryOption: DropdownOption | undefined =
saksdok !== undefined
? {
value: saksdok.category,
diff --git a/frontend/src/PagesAdmin/SultenMenuItemFormAdminPage/SultenMenuItemFormAdminPage.tsx b/frontend/src/PagesAdmin/SultenMenuItemFormAdminPage/SultenMenuItemFormAdminPage.tsx
index 5dd91813e..10dc0c12b 100644
--- a/frontend/src/PagesAdmin/SultenMenuItemFormAdminPage/SultenMenuItemFormAdminPage.tsx
+++ b/frontend/src/PagesAdmin/SultenMenuItemFormAdminPage/SultenMenuItemFormAdminPage.tsx
@@ -3,11 +3,12 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { SamfundetLogoSpinner } from '~/Components';
-import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
+import type { DropdownOption } from '~/Components/Dropdown/Dropdown';
import { SamfForm } from '~/Forms/SamfForm';
import { SamfFormField } from '~/Forms/SamfFormField';
import { getFoodCategories, getFoodPreferences, getMenuItem, postMenuItem, putMenuItem } from '~/api';
import type { FoodCategoryDto, FoodPreferenceDto, MenuItemDto } from '~/dto';
+import { useTitle } from '~/hooks';
import { STATUS } from '~/http_status_codes';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
@@ -38,8 +39,8 @@ export function SultenMenuItemFormAdminPage() {
const { id } = useParams();
const [showSpinner, setShowSpinner] = useState(true);
const [menuItem, setMenuItem] = useState>({});
- const [foodPreferenceOptions, setFoodPreferenceOptions] = useState[]>([]);
- const [foodCategoryOptions, setFoodCategoryOptions] = useState[]>([]);
+ const [foodPreferenceOptions, setFoodPreferenceOptions] = useState[]>([]);
+ const [foodCategoryOptions, setFoodCategoryOptions] = useState[]>([]);
const initialData: Partial = {
name_nb: menuItem?.name_nb,
@@ -57,6 +58,7 @@ export function SultenMenuItemFormAdminPage() {
const submitText = id ? t(KEY.common_save) : t(KEY.common_create);
const title = `${id ? t(KEY.common_edit) : t(KEY.common_create)} ${lowerCapitalize(`${t(KEY.sulten_dishes)}`)}`;
+ useTitle(title);
// Fetch data if edit mode.
@@ -70,7 +72,7 @@ export function SultenMenuItemFormAdminPage() {
({
label: dbT(category, 'name'),
value: category.id,
- }) as DropDownOption,
+ }) as DropdownOption,
),
);
})
@@ -85,7 +87,7 @@ export function SultenMenuItemFormAdminPage() {
({
label: dbT(preference, 'name'),
value: preference.id,
- }) as DropDownOption,
+ }) as DropdownOption,
),
);
})
diff --git a/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx b/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx
index 8bfd43b72..7fd36c020 100644
--- a/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx
+++ b/frontend/src/PagesAdmin/UsersAdminPage/UsersAdminPage.tsx
@@ -6,6 +6,7 @@ import { Table } from '~/Components/Table';
import { AdminPageLayout } from '~/PagesAdmin/AdminPageLayout/AdminPageLayout';
import { getUsers } from '~/api';
import type { UserDto } from '~/dto';
+import { useTitle } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { getFullName } from '~/utils';
import { ImpersonateButton } from './components';
@@ -15,6 +16,8 @@ export function UsersAdminPage() {
const [users, setUsers] = useState();
const [loading, setLoading] = useState(true);
+ const title = t(KEY.common_users);
+ useTitle(title);
useEffect(() => {
setLoading(true);
@@ -70,7 +73,7 @@ export function UsersAdminPage() {
}, [t, users]);
return (
-
+
);
diff --git a/frontend/src/PagesAdmin/index.ts b/frontend/src/PagesAdmin/index.ts
index e7740247b..d1ef78eba 100644
--- a/frontend/src/PagesAdmin/index.ts
+++ b/frontend/src/PagesAdmin/index.ts
@@ -29,6 +29,7 @@ export { RecruitmentUsersWithoutInterviewGangPage } from './RecruitmentUsersWith
export { RecruitmentUsersWithoutThreeInterviewCriteriaPage } from './RecruitmentUsersWithoutThreeInterviewCriteriaPage';
export { RolesAdminPage } from './RolesAdminPage';
export { RoleAdminPage } from './RoleAdminPage';
+export { RoleFormAdminPage } from './RoleFormAdminPage';
export { CreateInterviewRoomPage, RoomAdminPage } from './RoomAdminPage';
export { SaksdokumentAdminPage } from './SaksdokumentAdminPage';
export { SaksdokumentFormAdminPage } from './SaksdokumentFormAdminPage';
diff --git a/frontend/src/_constants.scss b/frontend/src/_constants.scss
index 190ac9ce1..0e014582d 100644
--- a/frontend/src/_constants.scss
+++ b/frontend/src/_constants.scss
@@ -24,6 +24,7 @@ $yellow: #e0a014;
$white: #ffffff;
$black: #000000;
$black-1: #161616; // small contrast to black
+$black-2: #222222;
$grey-5: #f4f4f4;
$grey-4: #eeeeee;
$grey-35: #cccccc;
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 56c9cc2ca..a099955de 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -20,6 +20,7 @@ import type {
MenuItemDto,
OccupiedTimeslotDto,
OrganizationDto,
+ PermissionDto,
PurchaseFeedbackDto,
RecruitmentApplicationDto,
RecruitmentApplicationRecruiterDto,
@@ -29,7 +30,10 @@ import type {
RecruitmentDto,
RecruitmentGangDto,
RecruitmentPositionDto,
+ RecruitmentPositionPostDto,
+ RecruitmentPositionPutDto,
RecruitmentSeparatePositionDto,
+ RecruitmentSharedInterviewGroupDto,
RecruitmentStatsDto,
RecruitmentUnprocessedApplicationsDto,
RecruitmentUserDto,
@@ -157,6 +161,13 @@ export async function putVenue(slug: string | number, venue: Partial):
return response.data;
}
+export async function getPermissions(): Promise {
+ const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__permissions_list;
+ const response = await axios.get(url, { withCredentials: true });
+
+ return response.data;
+}
+
export async function getInformationPages(): Promise {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__information_list;
const response = await axios.get(url, { withCredentials: true });
@@ -387,6 +398,33 @@ export async function getGang(id: string | number): Promise {
return response.data;
}
+export async function createRole(data: Partial): Promise {
+ const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__role_list });
+ const response = await axios.post(url, data, { withCredentials: true });
+ return response.data;
+}
+
+export async function editRole(data: RoleDto): Promise {
+ const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__role_detail, urlParams: { pk: data.id } });
+ const response = await axios.put(url, data, { withCredentials: true });
+
+ return response.data;
+}
+
+export async function getRoles(): Promise {
+ const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__role_list });
+ const response = await axios.get(url, { withCredentials: true });
+
+ return response.data;
+}
+
+export async function getRole(id: number): Promise {
+ const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__role_detail, urlParams: { pk: id } });
+ const response = await axios.get(url, { withCredentials: true });
+
+ return response.data;
+}
+
export async function getRecruitmentGangs(recruitmentId: string | number): Promise {
const url =
BACKEND_DOMAIN +
@@ -505,18 +543,6 @@ export async function getRecruitment(id: string): Promise {
- //const url = BACKEND_DOMAIN + reverse({ pattern: ROUTES.backend.samfundet__api_, urlParams: ( pk: id ) }):
- //const response = await axios.get(url, { withCredentials: true });
- const role = {
- id: 1,
- name: 'Opptaksansvarlig',
- permissions: ['samfundet.test_permission', 'samfundet.user_create'],
- };
- return role;
-}
-
export async function postRecruitment(recruitmentData: RecruitmentDto): Promise {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__recruitment_list;
const response = await axios.post(url, recruitmentData, { withCredentials: true });
@@ -629,7 +655,7 @@ export async function getRecruitmentPosition(positionId: string): Promise {
+export async function postRecruitmentPosition(recruitmentPosition: RecruitmentPositionPostDto): Promise {
const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__recruitment_position_list;
const response = await axios.post(url, recruitmentPosition, { withCredentials: true });
@@ -638,7 +664,7 @@ export async function postRecruitmentPosition(recruitmentPosition: RecruitmentPo
export async function putRecruitmentPosition(
positionId: string,
- recruitment: Partial,
+ recruitment: Partial,
): Promise {
const url =
BACKEND_DOMAIN +
@@ -793,6 +819,20 @@ export async function getRecruitmentApplicationsForGang(
return await axios.get(url, { withCredentials: true });
}
+export async function getRecruitmentSharedInterviewGroups(
+ recruitmentId: string,
+): Promise> {
+ const url =
+ BACKEND_DOMAIN +
+ reverse({
+ pattern: ROUTES.backend.samfundet__recruitment_shared_interviews,
+ urlParams: {
+ recruitmentId: recruitmentId,
+ },
+ });
+ return await axios.get(url, { withCredentials: true });
+}
+
export async function downloadCSVGangRecruitment(recruitmentId: string, gangId: string): Promise {
const url =
BACKEND_DOMAIN +
@@ -974,6 +1014,16 @@ export async function putRecruitmentApplicationInterview(
return response;
}
+export async function getInterview(interviewId: number): Promise> {
+ const url =
+ BACKEND_DOMAIN +
+ reverse({
+ pattern: ROUTES.backend.samfundet__interview_detail,
+ urlParams: { pk: interviewId },
+ });
+ return await axios.get(url, { withCredentials: true });
+}
+
// ############################################################
// Interview rooms
// ############################################################
@@ -1023,12 +1073,12 @@ export async function postPurchaseFeedback(feedback: PurchaseFeedbackDto): Promi
return response.data;
}
-export async function getRecruitmentStats(id: string): Promise> {
+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/dto.ts b/frontend/src/dto.ts
index 88710ec19..3a538bd24 100644
--- a/frontend/src/dto.ts
+++ b/frontend/src/dto.ts
@@ -312,8 +312,8 @@ export type GangDto = {
abbreviation: string;
webpage?: string;
logo?: string;
- gang_type?: number;
- info_page?: string;
+ gang_type?: number | null;
+ info_page?: string | null;
};
export type RecruitmentGangDto = GangDto & {
@@ -379,7 +379,8 @@ export type KeyValueDto = {
export type RoleDto = {
id: number;
name: string;
- permissions: string[];
+ permissions: number[];
+ content_type?: string | null;
};
export type UserGangRoleDto = {
@@ -414,6 +415,7 @@ export type RecruitmentDto = {
organization: number | OrganizationDto;
separate_positions?: RecruitmentSeparatePositionDto[];
recruitment_progress?: number;
+ promo_media?: string;
};
export type RecruitmentSeparatePositionDto = {
@@ -426,6 +428,14 @@ export type RecruitmentSeparatePositionDto = {
recruitment?: string;
};
+export type RecruitmentSharedInterviewGroupDto = {
+ id?: number;
+ recruitment?: string;
+ name_nb: string;
+ name_en: string;
+ positions: RecruitmentPositionDto[];
+};
+
export type UserPriorityDto = {
direction: number;
};
@@ -460,6 +470,16 @@ export type RecruitmentPositionDto = {
accepted_applicants?: number;
};
+export type RecruitmentPositionPostDto = Omit & {
+ gang: { id: number };
+ interviewer_ids?: number[];
+};
+
+export type RecruitmentPositionPutDto = Omit & {
+ gang: { id: number };
+ interviewer_ids?: number[];
+};
+
export type RecruitmentRecruitmentPositionDto = {
id: number;
name_nb: string;
@@ -485,7 +505,7 @@ export type RecruitmentApplicationDto = {
recruitment: number;
user: UserDto;
applicant_priority: number;
- recruiter_priority?: number | string;
+ recruiter_priority?: number;
recruiter_status?: number;
applicant_state?: number;
created_at: string;
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;
+}
diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts
index b72ba1f1a..a237bf674 100644
--- a/frontend/src/i18n/constants.ts
+++ b/frontend/src/i18n/constants.ts
@@ -54,6 +54,7 @@ export const KEY = {
// Date related:
common_today: 'common_today',
common_tomorrow: 'common_tomorrow',
+ pick_a_date: 'pick_a_date',
// No category:
common_url: 'common_url',
@@ -61,6 +62,7 @@ export const KEY = {
common_yes: 'common_yes',
common_no: 'common_no',
common_to: 'common_to',
+ common_any: 'common_any',
common_all: 'common_all',
common_price: 'common_price',
common_food: 'common_food',
@@ -83,6 +85,7 @@ export const KEY = {
common_card: 'common_card',
common_tags: 'common_tags',
common_gang: 'common_gang',
+ common_section: 'common_section',
common_next: 'common_next',
common_open: 'common_open',
common_send: 'common_send',
@@ -122,7 +125,7 @@ export const KEY = {
common_email_subject: 'common_email_subject',
common_total: 'common_total',
common_roles: 'common_roles',
- common_roles_view: 'common_roles_view',
+ common_role: 'common_role',
common_guests: 'common_guests',
common_occasion: 'common_occasion',
common_phonenumber: 'common_phonenumber',
@@ -215,6 +218,8 @@ export const KEY = {
// Others //
// ==================== //
+ role_content_type: 'role_content_type',
+
// LoginPage:
loginpage_register: 'loginpage_register',
loginpage_internal_login: 'loginpage_internal_login',
@@ -227,6 +232,8 @@ export const KEY = {
contributors_page_text: 'contributors_page_text',
contributors_page_past_developers: 'contributors_page_past_developers',
+ role_edit_could_not_load_permissions: 'role_edit_could_not_load_permissions',
+
// GroupsPage:
groupspage_gangs_text: 'groupspage_gangs_text',
groupspage_gangs_title: 'groupspage_gangs_title',
@@ -270,9 +277,14 @@ export const KEY = {
recruitment_position: 'recruitment_position',
recruitment_positions: 'recruitment_positions',
recruitment_applicant: 'recruitment_applicant',
+ recruitment_interviewer: 'recruitment_interviewer',
+ recruitment_interviewers: 'recruitment_interviewers',
recruitment_interviews: 'recruitment_interviews',
+ recruitment_interview_planned: 'recruitment_interview_planned',
recruitment_no_interviews: 'recruitment_no_interviews',
recruitment_interview_set: 'recruitment_interview_set',
+ recruitment_interview_groups: 'recruitment_interview_groups',
+ recruitment_interview_group: 'recruitment_interview_group',
recruitment_applicants: 'recruitment_applicants',
recruitment_interview_time: 'recruitment_interview_time',
recruitment_interview_location: 'recruitment_interview_location',
@@ -310,6 +322,7 @@ export const KEY = {
recrutment_default_application_letter: 'recrutment_default_application_letter',
reprioritization_deadline_for_groups: 'reprioritization_deadline_for_groups',
max_applications: 'max_applications',
+ recruitment_promo_media: 'recruitment_promo_media',
recruitment_norwegian_applicants_only: 'recruitment_norwegian_applicants_only',
reprioritization_deadline_for_applicant: 'reprioritization_deadline_for_applicant',
recruitment_show_unprocessed_applicants: 'recruitment_show_unprocessed_applicants',
@@ -328,15 +341,19 @@ 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',
recruitment_withdrawn_message: 'recruitment_withdrawn_message',
+ recruitment_confirm_withdraw_application: 'recruitment_confirm_withdraw_application',
recruitment_three_interviews_criteria_button: 'recruitment_three_interviews_criteria_button',
recruitment_three_interviews_criteria_header: 'recruitment_three_interviews_criteria_header',
recruitment_revert_status: 'recruitment_revert_status',
@@ -370,11 +387,15 @@ export const KEY = {
recruitment_applet_edit_description: 'recruitment_applet_edit_description',
recruitment_applet_open_to_other_positions: 'recruitment_applet_open_to_other_positions',
recruitment_applet_overview_description: 'recruitment_applet_overview_description',
+ recruitment_applet_room_overview: 'recruitment_applet_room_overview',
recruitment_applet_room_description: 'recruitment_applet_room_description',
recruitment_applet_rejection_mail_description: 'recruitment_applet_rejection_mail_description',
recruitment_applet_without_interview_title: 'recruitment_applet_without_interview_title',
recruitment_applet_without_interview_description: 'recruitment_applet_without_interview_description',
recruitment_applet_three_interview_title: 'recruitment_applet_three_interview_title',
+ recruitment_choose_interview_time_and_location: 'recruitment_choose_interview_time_and_location',
+ recruitment_choose_interview_time: 'recruitment_choose_interview_time',
+ recruitment_choose_interview_location: 'recruitment_choose_interview_location',
// Admin:
admin_users_last_active: 'admin_users_last_active',
@@ -451,6 +472,17 @@ export const KEY = {
notfoundpage_contact_prompt: 'notfoundpage_contact_prompt',
saksdokumentpage_publication_date: 'saksdokumentpage_publication_date',
eventsadminpage_successful_delete_toast: 'eventsadminpage_successful_delete_toast',
+ promo_media_description: 'promo_media_description',
+ promo_media_invalid: 'promo_media_invalid',
+
+ error_generic: 'error_generic',
+ error_generic_description: 'error_generic_description',
+ error_not_found: 'error_not_found',
+ error_not_found_description: 'error_not_found_description',
+ error_forbidden: 'error_forbidden',
+ error_forbidden_description: 'error_forbidden_description',
+ error_server_error: 'error_server_error',
+ error_server_error_description: 'error_server_error_description',
} as const;
// This will ensure that each value matches the key exactly.
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,
diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts
index 53853cf96..5e693cd43 100644
--- a/frontend/src/i18n/translations.ts
+++ b/frontend/src/i18n/translations.ts
@@ -40,6 +40,7 @@ export const nb = prepareTranslations({
// Date related
[KEY.common_today]: 'I dag',
[KEY.common_tomorrow]: 'I morgen',
+ [KEY.pick_a_date]: 'Velg en dato',
// Other common
[KEY.common_url]: 'URL',
@@ -50,6 +51,7 @@ export const nb = prepareTranslations({
[KEY.common_all]: 'Alle',
[KEY.common_price]: 'Pris',
[KEY.common_food]: 'Mat',
+ [KEY.common_any]: 'Hvilken som helst',
[KEY.common_preferences]: 'Preferanser',
[KEY.common_buy]: 'Kjøp',
[KEY.common_not]: 'Ikke',
@@ -66,8 +68,9 @@ export const nb = prepareTranslations({
[KEY.common_next]: 'Neste',
[KEY.common_save]: 'Lagre',
[KEY.common_gang]: 'Gjeng',
- [KEY.common_table]: 'Bord',
[KEY.common_gangs]: 'Gjenger',
+ [KEY.common_section]: 'Seksjon',
+ [KEY.common_table]: 'Bord',
[KEY.common_gang_type]: 'Gjengtype',
[KEY.common_edit]: 'Rediger',
[KEY.common_sheet]: 'Tabell',
@@ -81,7 +84,7 @@ export const nb = prepareTranslations({
[KEY.common_login]: 'Logg inn',
[KEY.common_users]: 'Brukere',
[KEY.common_roles]: 'Roller',
- [KEY.common_roles_view]: 'Rolle',
+ [KEY.common_role]: 'Rolle',
[KEY.common_active]: 'Aktiv',
[KEY.common_event]: 'Arrangement',
[KEY.common_repeat]: 'Repeter',
@@ -199,6 +202,7 @@ export const nb = prepareTranslations({
// ==================== //
// Others //
// ==================== //
+ [KEY.role_content_type]: 'Hierarkinivå',
[KEY.admin_impersonate]: 'Stjel identitet',
[KEY.admin_stop_impersonate]: 'Stopp identitetstyveri',
@@ -258,8 +262,13 @@ export const nb = prepareTranslations({
[KEY.recruitment_not_applied]: 'Du har ikke sendt søknader til noen stillinger ennå',
[KEY.recruitment_will_be_anonymized]: 'All info relatert til dine søknader vil bli slettet 3 uker etter opptaket',
[KEY.recruitment_interviews]: 'Intervjuer',
+ [KEY.recruitment_interview_planned]: 'Intervjuer planlagt',
+ [KEY.recruitment_interviewer]: 'Intervjuer',
+ [KEY.recruitment_interviewers]: 'Intervjuere',
[KEY.recruitment_no_interviews]: 'Ingen intervjuer',
[KEY.recruitment_interview_set]: 'Sett intervju',
+ [KEY.recruitment_interview_groups]: 'Intervjugrupper',
+ [KEY.recruitment_interview_group]: 'Intervjugrupper',
[KEY.recruitment_interview_time]: 'Intervjutid',
[KEY.recruitment_interview_location]: 'Intervjusted',
[KEY.recruitment_no_positions]: 'Ingen verv',
@@ -299,6 +308,7 @@ export const nb = prepareTranslations({
[KEY.recrutment_default_application_letter]: 'Standard søknadstekst',
[KEY.reprioritization_deadline_for_groups]: 'Flaggefrist',
[KEY.max_applications]: 'Maks søknader per bruker',
+ [KEY.recruitment_promo_media]: 'Promo video',
[KEY.recruitment_norwegian_applicants_only]: 'Kun norsktalende søkere',
[KEY.reprioritization_deadline_for_applicant]: 'Omprioriteringsfrist',
[KEY.recruitment_show_unprocessed_applicants]: 'Vis ubehandlede søkere',
@@ -323,16 +333,21 @@ 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',
+ [KEY.recruitment_confirm_withdraw_application]: 'Er du sikker på at du vil trekke søknaden?',
[KEY.recruitment_withdrawn_message]: 'Du har trukket søknaden din fra denne stillingen',
[KEY.recruitment_stats_hours_header]: 'Antall søkere per time',
[KEY.recruitment_stats_campus_header]: 'Antall søkere per campus',
@@ -351,6 +366,9 @@ export const nb = prepareTranslations({
[KEY.recruitment_three_interviews_criteria_header]:
'Søkere med færre enn 3 intervjuer, men har søkte på flere enn 3 verv',
[KEY.recruitment_three_interviews_criteria_button]: 'Søkere med mindre enn 3 intervjuer',
+ [KEY.recruitment_choose_interview_time_and_location]: 'Velg tid og sted for intervju',
+ [KEY.recruitment_choose_interview_time]: 'Velg intervjutid',
+ [KEY.recruitment_choose_interview_location]: 'Velg intervjusted',
[KEY.recruitment_dashboard_description]:
'Her har man oversikt over dine kommende intervjuer, stillinger du har noe å gjøre med, og kan sette tider du er tilgjengelig til å avholde intervjuer',
// Admin:
@@ -381,6 +399,7 @@ export const nb = prepareTranslations({
[KEY.recruitment_applet_edit_description]: 'Rediger opptaket',
[KEY.recruitment_applet_open_to_other_positions]: 'Åpen for mer',
[KEY.recruitment_applet_overview_description]: 'Se hvor langt opptaket har kommet',
+ [KEY.recruitment_applet_room_overview]: 'Romoversikt',
[KEY.recruitment_applet_room_description]: 'Opprett rom for intervju',
[KEY.recruitment_applet_rejection_mail_description]: 'Send mail til de som ikke fikk tilbud',
[KEY.recruitment_applet_without_interview_title]: 'Uten intervju',
@@ -406,6 +425,8 @@ export const nb = prepareTranslations({
[KEY.command_menu_shortcut_control_panel]: 'Kontrollpanel',
[KEY.command_menu_shortcut_about_samfundet]: 'Om Samfundet',
+ [KEY.role_edit_could_not_load_permissions]: 'Klarte ikke laste inn tilganger',
+
// Sulten / Lyche
[KEY.sulten_what_is_lyche]: 'Hva er Lyche?',
[KEY.sulten_page_see_menu]: 'Se meny',
@@ -451,6 +472,19 @@ export const nb = prepareTranslations({
[KEY.notfoundpage_contact_prompt]: 'Hvis du tror dette er en feil, vennligst',
[KEY.admin_saksdokumenter_cannot_reupload]: 'Det er ikke mulig å endre filen som er lastet opp.',
[KEY.eventsadminpage_successful_delete_toast]: 'Slettingen av arrangementet var vellykket.',
+
+ [KEY.promo_media_description]: 'Lenke eller ID til en YouTube-video',
+ [KEY.promo_media_invalid]: 'Må være en gyldig lenke til en YouTube-video',
+
+ // Errors
+ [KEY.error_generic]: 'Uventet feil',
+ [KEY.error_generic_description]: 'En uventet feil oppsto, vennligst prøv igjen',
+ [KEY.error_not_found]: 'Ikke funnet',
+ [KEY.error_not_found_description]: 'Ressursen du forsøkte å hente ble ikke funnet',
+ [KEY.error_forbidden]: 'Ingen adgang',
+ [KEY.error_forbidden_description]: 'Du har ikke adgang til å se denne ressursen',
+ [KEY.error_server_error]: 'Serverfeil',
+ [KEY.error_server_error_description]: 'En serverfeil har opptstått',
});
export const en = prepareTranslations({
@@ -492,6 +526,7 @@ export const en = prepareTranslations({
// Date related
[KEY.common_today]: 'Today',
[KEY.common_tomorrow]: 'Tomorrow',
+ [KEY.pick_a_date]: 'Pick a date',
// No category:
[KEY.common_url]: 'URL',
@@ -502,6 +537,7 @@ export const en = prepareTranslations({
[KEY.common_all]: 'All',
[KEY.common_price]: 'Price',
[KEY.common_food]: 'Food',
+ [KEY.common_any]: 'Any',
[KEY.common_preferences]: 'Preferences',
[KEY.common_buy]: 'Buy',
[KEY.common_not]: 'Not',
@@ -525,6 +561,7 @@ export const en = prepareTranslations({
[KEY.common_gang]: 'Gang',
[KEY.common_gangs]: 'Gangs',
[KEY.common_gang_type]: 'Gang type',
+ [KEY.common_section]: 'Section',
[KEY.common_max]: 'Max',
[KEY.common_image]: 'Image',
[KEY.common_venue]: 'Venue',
@@ -533,7 +570,7 @@ export const en = prepareTranslations({
[KEY.common_login]: 'Log in',
[KEY.common_users]: 'Users',
[KEY.common_roles]: 'Roles',
- [KEY.common_roles_view]: 'Role',
+ [KEY.common_role]: 'Role',
[KEY.common_active]: 'Active',
[KEY.common_cancel]: 'Cancel',
[KEY.common_venues]: 'Venues',
@@ -651,6 +688,7 @@ export const en = prepareTranslations({
// ==================== //
// Others //
// ==================== //
+ [KEY.role_content_type]: 'Hierarchical level',
[KEY.admin_impersonate]: 'Impersonate',
[KEY.admin_stop_impersonate]: 'Stop impersonation',
@@ -710,8 +748,13 @@ export const en = prepareTranslations({
[KEY.recruitment_will_be_anonymized]:
'All info related to the applications will be anonymized three weeks after the recruitment is over',
[KEY.recruitment_interviews]: 'Interviews',
+ [KEY.recruitment_interview_planned]: 'Interviews planned',
+ [KEY.recruitment_interviewer]: 'Interviewer',
+ [KEY.recruitment_interviewers]: 'Interviewers',
[KEY.recruitment_no_interviews]: 'No interviews',
[KEY.recruitment_interview_set]: 'Set Interview',
+ [KEY.recruitment_interview_groups]: 'Interview groups',
+ [KEY.recruitment_interview_group]: 'Interview group',
[KEY.recruitment_interview_time]: 'Interview Time',
[KEY.recruitment_interview_location]: 'Interview Location',
[KEY.recruitment_interview_notes]: 'Interview notes',
@@ -750,6 +793,7 @@ export const en = prepareTranslations({
[KEY.recrutment_default_application_letter]: 'Default application letter',
[KEY.reprioritization_deadline_for_groups]: 'Group reprioritization deadline',
[KEY.max_applications]: 'Max applications per user',
+ [KEY.recruitment_promo_media]: 'Promo video',
[KEY.reprioritization_deadline_for_applicant]: 'Reprioritization deadline',
[KEY.recruitment_show_unprocessed_applicants]: 'Show unprocessed applicants',
[KEY.recruitment_show_all_applicants]: 'Show all applicants',
@@ -775,17 +819,23 @@ 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',
+ [KEY.recruitment_confirm_withdraw_application]: 'Are you sure you want to withdraw this application?',
[KEY.recruitment_revert_status]: 'Revert status',
[KEY.recruitment_no_current_positions]: 'This gang does not have any available positions for this recruitment',
[KEY.recruitment_no_current_applications_position]: 'This gang has no current applicants',
@@ -811,11 +861,15 @@ export const en = prepareTranslations({
[KEY.recruitment_applet_edit_description]: 'Edit recruitment',
[KEY.recruitment_applet_open_to_other_positions]: 'Open for more',
[KEY.recruitment_applet_overview_description]: 'See how far the recruitment has come',
+ [KEY.recruitment_applet_room_overview]: 'Room overview',
[KEY.recruitment_applet_room_description]: 'Create room for interviews',
[KEY.recruitment_applet_rejection_mail_description]: "Send mail to those who didn't get an offer",
[KEY.recruitment_applet_without_interview_title]: 'Without interview',
[KEY.recruitment_applet_without_interview_description]: 'Applicants without any interviews set',
[KEY.recruitment_applet_three_interview_title]: '< 3 interviews',
+ [KEY.recruitment_choose_interview_time_and_location]: 'Choose time and location of interview',
+ [KEY.recruitment_choose_interview_time]: 'Choose time of interview',
+ [KEY.recruitment_choose_interview_location]: 'Choose location of interview',
// Admin:
[KEY.admin_organizer]: 'Organizer',
@@ -839,6 +893,10 @@ export const en = prepareTranslations({
[KEY.admin_information_confirm_cancel]: 'Are you sure you want to exit without saving?',
[KEY.admin_saksdokumenter_cannot_reupload]: 'It is not possible to change the uploaded file.',
[KEY.eventsadminpage_successful_delete_toast]: 'Succsessfully deleted event.',
+
+ [KEY.promo_media_description]: 'Link or ID of a YouTube video',
+ [KEY.promo_media_invalid]: 'Must be a valid link to a YouTube video',
+
[KEY.recruitment_three_interviews_criteria_header]:
'Applicants with less than 3 interviews, but have applied to more than 3 positions',
[KEY.recruitment_three_interviews_criteria_button]: 'Applicants with less than 3 interviews',
@@ -861,6 +919,8 @@ export const en = prepareTranslations({
[KEY.command_menu_shortcut_control_panel]: 'Control panel',
[KEY.command_menu_shortcut_about_samfundet]: 'About Samfundet',
+ [KEY.role_edit_could_not_load_permissions]: 'Could not load permissions',
+
// Sulten / Lyche:
[KEY.sulten_dishes]: 'Dishes',
[KEY.sulten_lyche_goal]: 'Lyches goal',
@@ -905,4 +965,13 @@ export const en = prepareTranslations({
[KEY.inputfile_no_file_selected]: 'No file selected',
[KEY.notfoundpage_title]: 'Page not found',
[KEY.notfoundpage_contact_prompt]: 'If you believe this is an error, please',
+
+ [KEY.error_generic]: 'Unexpected error',
+ [KEY.error_generic_description]: 'An unexpected error has occurred',
+ [KEY.error_not_found]: 'Not found',
+ [KEY.error_not_found_description]: 'The resource you requested was not found',
+ [KEY.error_forbidden]: 'Forbidden',
+ [KEY.error_forbidden_description]: 'You do not have permission to view this resource',
+ [KEY.error_server_error]: 'Server error',
+ [KEY.error_server_error_description]: 'A server error has occurred',
});
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index 2673b9d11..a11afa3bb 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -2,15 +2,30 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { AuthContextProvider } from '~/context/AuthContext';
import '~/global.scss';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { t } from 'i18next';
import { RouterProvider } from 'react-router-dom';
+import { toast } from 'react-toastify';
import { GlobalContextProvider } from '~/context/GlobalContextProvider';
import { OrganizationContextProvider } from '~/context/OrgContextProvider';
+import { KEY } from '~/i18n/constants';
import { reportWebVitals } from '~/reportWebVitals';
import { router } from '~/router/router';
-const queryClient = new QueryClient();
+const queryClient = new QueryClient({
+ queryCache: new QueryCache({
+ onError: (error, query) => {
+ console.error(error);
+ toast.error((query.meta?.errorMsg as string) ?? t(KEY.common_something_went_wrong));
+ },
+ }),
+ defaultOptions: {
+ queries: {
+ retry: 2, // default is 3
+ },
+ },
+});
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
diff --git a/frontend/src/queryKeys.ts b/frontend/src/queryKeys.ts
new file mode 100644
index 000000000..50e009dee
--- /dev/null
+++ b/frontend/src/queryKeys.ts
@@ -0,0 +1,23 @@
+export const roleKeys = {
+ all: ['roles'] as const,
+ lists: () => [...roleKeys.all, 'list'] as const,
+ list: (filters: unknown[]) => [...roleKeys.lists(), { filters }] as const,
+ details: () => [...roleKeys.all, 'detail'] as const,
+ detail: (id: number) => [...roleKeys.details(), id] as const,
+};
+
+export const infoPageKeys = {
+ all: ['infopages'] as const,
+ lists: () => [...infoPageKeys.all, 'list'] as const,
+ list: (filters: unknown[]) => [...infoPageKeys.lists(), { filters }] as const,
+ details: () => [...infoPageKeys.all, 'detail'] as const,
+ detail: (slug: string) => [...infoPageKeys.details(), slug] as const,
+};
+
+export const permissionKeys = {
+ all: ['permissions'] as const,
+ lists: () => [...permissionKeys.all, 'list'] as const,
+ list: (filters: unknown[]) => [...permissionKeys.lists(), { filters }] as const,
+ details: () => [...permissionKeys.all, 'detail'] as const,
+ detail: (id: number) => [...permissionKeys.details(), id] as const,
+};
diff --git a/frontend/src/router/loaders.ts b/frontend/src/router/loaders.ts
index 2104ec33f..c3c1ec630 100644
--- a/frontend/src/router/loaders.ts
+++ b/frontend/src/router/loaders.ts
@@ -23,7 +23,7 @@ export type RoleLoader = {
};
export async function roleLoader({ params }: LoaderFunctionArgs): Promise {
- return { role: await getRole(params.roleId as string) };
+ return { role: await getRole(Number.parseInt(params.roleId as string)) };
}
export async function recruitmentLoader({ params }: LoaderFunctionArgs): Promise {
diff --git a/frontend/src/router/router.tsx b/frontend/src/router/router.tsx
index 4da5f020a..662f7098f 100644
--- a/frontend/src/router/router.tsx
+++ b/frontend/src/router/router.tsx
@@ -1,5 +1,5 @@
import { Outlet, Route, type UIMatch, createBrowserRouter, createRoutesFromElements } from 'react-router-dom';
-import { Link, PermissionRoute, ProtectedRoute, SamfOutlet, SultenOutlet } from '~/Components';
+import { Link, PermissionRoute, ProtectedRoute, RootErrorBoundary, SamfOutlet, SultenOutlet } from '~/Components';
import {
AboutPage,
AdminPage,
@@ -60,6 +60,7 @@ import {
RecruitmentUsersWithoutInterviewGangPage,
RecruitmentUsersWithoutThreeInterviewCriteriaPage,
RoleAdminPage,
+ RoleFormAdminPage,
RolesAdminPage,
RoomAdminPage,
SaksdokumentAdminPage,
@@ -74,8 +75,8 @@ import { ROUTES } from '~/routes';
import { t } from 'i18next';
import { App } from '~/App';
+import { DynamicOrgOutlet } from '~/Components/DynamicOrgOutlet/DynamicOrgOutlet';
import { RecruitmentRecruiterDashboardPage } from '~/PagesAdmin/RecruitmentRecruiterDashboardPage/RecruitmentRecruiterDashboardPage';
-import { RoleDto } from '~/dto';
import { KEY } from '~/i18n/constants';
import { reverse } from '~/named-urls';
import {
@@ -100,34 +101,40 @@ export const router = createBrowserRouter(
{/*
PUBLIC ROUTES
*/}
- } />
- } />
- } />
- } />
- } />
- } />}>
- } />
- } />
+ } errorElement={ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />}>
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ {/* Recruitment */}
+ } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+
+ {/* Specific recruitment */}
+ } id="publicRecruitment" loader={recruitmentLoader}>
} />
}
/>
} />
- } />
- } />
- } />
{/*
ADMIN ROUTES
@@ -136,465 +143,496 @@ export const router = createBrowserRouter(
handle={{ crumb: () => {t(KEY.control_panel_title)} }}
element={ }
>
- } />}
- />
- {/* Gangs */}
- }
- handle={{ crumb: () => {t(KEY.common_gangs)} }}
- >
- } />}
- />
- {t(KEY.common_create)} }}
- element={ } />}
- />
- } />}
- loader={gangLoader}
- handle={{
- crumb: ({ pathname }: UIMatch) => {t(KEY.common_edit)},
- }}
- />
-
- {/* Users */}
- }
- handle={{ crumb: () => {t(KEY.common_users)} }}
- >
- } />}
- />
-
- {/* Roles */}
- }
- handle={{ crumb: () => {t(KEY.common_roles)} }}
- >
- } />}
- />
- } />}
- loader={roleLoader}
- handle={{
- crumb: ({ pathname }: UIMatch, { role }: RoleLoader) => {role?.name},
- }}
- />
-
- {/* Events */}
- }
- handle={{ crumb: () => {t(KEY.common_events)} }}
- >
- } />}
- />
- {t(KEY.common_create)} }}
- element={ } />}
- />
+ } errorElement={ }>
{t(KEY.common_edit)} }}
- element={ } />}
+ path={ROUTES.frontend.admin}
+ element={ } />}
/>
-
- {/*
- Info pages
- NOTE: edit/create uses custom views
- */}
- {t(KEY.information_page)} }}
- element={
- } />
- }
- />
- {/* Opening hours, TODO ADD OPENING HOURS PERMISSIONS*/}
- {t(KEY.common_opening_hours)} }}
- element={ } />}
- />
- {/* Closed period */}
- }
- handle={{
- crumb: () => {t(KEY.command_menu_shortcut_closed)},
- }}
- >
+ {/* Gangs */}
} />
- }
- />
+ element={ }
+ handle={{ crumb: () => {t(KEY.common_gangs)} }}
+ >
+ } />}
+ />
+