Repository: virkarekisteri/virkarekisteri
Pull request #178: Task/130 control panel for position names
Author: login: hwps
Created: 2025-04-21T10:54:11Z | Merged: 2025-04-27T08:26:25Z

https://github.com/virkarekisteri/virkarekisteri/issues/130


Diff:
diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json
index 66fb165..0d68e74 100644
--- a/frontend/public/locales/en/translation.json
+++ b/frontend/public/locales/en/translation.json
@@ -132,6 +132,7 @@
       "CreatedEmployee": "Added new employee",
       "CreatedCostcentre": "Added new costcentre",
       "CreatedSubject": "Added new subject",
+      "CreatedPositionName": "Added new position name",
       "VacancyStatus": "Vacancy status",
       "EndedPosition": "Ended position",
       "PositionSubjects": "Subjects",
@@ -257,6 +258,23 @@
         "invalid_date": "Invalid time period"
       }
     },
+    "position_name": {
+      "name": "Name",
+      "time_frame": "Time frame",
+      "valid_from": "Valid from",
+      "valid_until": "Valid until",
+      "create_new": "Create new position name",
+      "edit": "Edit position name",
+      "edit_warning": "Any changes made here will affect all positions using this position name.",
+      "edit_history": "Editing history",
+      "show_only_active": "Show only active",
+      "details": "Position name details",
+      "validation": {
+        "required": "Field is required",
+        "duplicate_name": "Position name already exists",
+        "invalid_date": "Invalid time period"
+      }
+    },
     "change_logs": {
       "edited_field": "Edited field",
       "old_value": "Old value",
diff --git a/frontend/public/locales/fi/translation.json b/frontend/public/locales/fi/translation.json
index 4df3c25..4ad4811 100644
--- a/frontend/public/locales/fi/translation.json
+++ b/frontend/public/locales/fi/translation.json
@@ -132,6 +132,7 @@
       "CreatedEmployee": "Lisätty uusi viranhaltija",
       "CreatedCostcentre": "Luotu uusi kustannuspaikka",
       "CreatedSubject": "Luotu uusi aine",
+      "CreatedPositionName": "Luotu uusi virkanimike",
       "VacancyStatus": "Vakanssin tila",
       "EndedPosition": "Lakkautettu virka",
       "PositionSubjects": "Aineet",
@@ -257,6 +258,23 @@
         "invalid_date": "Virheellinen voimassaoloaika"
       }
     },
+    "position_name": {
+      "name": "Nimi",
+      "time_frame": "Aikaväli",
+      "valid_from": "Voimassa alkaen",
+      "valid_until": "Voimassa päättyen",
+      "create_new": "Luo uusi virkanimike",
+      "edit": "Muokkaa virkanimikkeen tietoja",
+      "show_only_active": "Näytä vain aktiiviset",
+      "details": "Virkanimikkeen tiedot",
+      "edit_warning": "Tässä tehdyt muutokset vaikuttavat kaikkiin tämän virkanimikkeen virkoihin.",
+      "edit_history": "Muutoshistoria",
+      "validation": {
+        "required": "Kenttä on pakollinen",
+        "duplicate_name": "Virkanimike on jo käytössä",
+        "invalid_date": "Virheellinen voimassaoloaika"
+      }
+    },
     "change_logs": {
       "edited_field": "Muokattu kenttä",
       "old_value": "Vanha arvo",
diff --git a/frontend/public/locales/sv/translation.json b/frontend/public/locales/sv/translation.json
index 7919f6d..a8c0887 100644
--- a/frontend/public/locales/sv/translation.json
+++ b/frontend/public/locales/sv/translation.json
@@ -1,6 +1,6 @@
 {
   "additional_details": "",
-  "app_name": "jag talar inte svenska",
+  "app_name": "Tjänsteregister",
   "create_position": {
     "add_new_position": "",
     "created_at": "",
@@ -61,15 +61,15 @@
     "ended_at": "",
     "creation_decision_number": "",
     "ending_decision_number": "",
-    "placement_location": "",
     "type": "",
     "vacancy_number": "",
     "vacancy_size": "",
     "vacancy_status": "",
     "rows_per_page": "",
+    "position_name": "",
     "costcentre": "",
     "other": "",
-    "position_name": "",
+    "placement_location": "",
     "employee_name": "",
     "replacement_name": "",
     "replacement_start_date": "",
@@ -132,6 +132,7 @@
       "CreatedEmployee": "",
       "CreatedCostcentre": "",
       "CreatedSubject": "",
+      "CreatedPositionName": "",
       "VacancyStatus": "",
       "EndedPosition": "",
       "PositionSubjects": "",
@@ -257,6 +258,23 @@
         "invalid_date": ""
       }
     },
+    "position_name": {
+      "name": "",
+      "time_frame": "",
+      "valid_from": "",
+      "valid_until": "",
+      "create_new": "",
+      "edit": "",
+      "show_only_active": "",
+      "details": "",
+      "edit_warning": "",
+      "edit_history": "",
+      "validation": {
+        "required": "",
+        "duplicate_name": "",
+        "invalid_date": ""
+      }
+    },
     "change_logs": {
       "edited_field": "",
       "old_value": "",
diff --git a/frontend/src/components/AdminPanel/AdminPanelContainer.tsx b/frontend/src/components/AdminPanel/AdminPanelContainer.tsx
index 5bf0460..1c51111 100644
--- a/frontend/src/components/AdminPanel/AdminPanelContainer.tsx
+++ b/frontend/src/components/AdminPanel/AdminPanelContainer.tsx
@@ -4,6 +4,7 @@ import SubjectAdmin from './SubjectAdmin';
 // import JobtitleAdmin from './JobtitleAdmin';
 import CostcentreAdmin from './CostcentreAdmin';
 import { useTranslation } from 'react-i18next';
+import PositionNameAdmin from './PositionNameAdmin';
 
 const AdminPanelContainer = () => {
   const { t } = useTranslation();
@@ -57,7 +58,7 @@ const AdminPanelContainer = () => {
       <Box sx={{ p: 2 }}>
         {selectedTab === 0 && <SubjectAdmin />}
         {selectedTab === 1 && <CostcentreAdmin />}
-        {/* {selectedTab === 2 && <JobtitleAdmin />} */}
+        {selectedTab === 2 && <PositionNameAdmin />}
       </Box>
     </Box>
   );
diff --git a/frontend/src/components/AdminPanel/PositionNameAdmin.tsx b/frontend/src/components/AdminPanel/PositionNameAdmin.tsx
new file mode 100644
index 0000000..6f99cec
--- /dev/null
+++ b/frontend/src/components/AdminPanel/PositionNameAdmin.tsx
@@ -0,0 +1,371 @@
+import React, { useState, useEffect } from 'react';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableContainer,
+  TableHead,
+  TableRow,
+  Box,
+  CircularProgress,
+  alpha,
+  TablePagination,
+  TextField,
+  Button,
+  Grid2,
+  Checkbox,
+  FormControlLabel,
+} from '@mui/material';
+
+import { useTranslation } from 'react-i18next';
+
+import type { PositionName } from 'models/PositionName';
+
+import { useGetPositionNamesQuery } from 'redux/api-slices/functions/position-names-api';
+import PositionNameDetails from 'components/Details/PositionNameDetails';
+import { RequiresEditRole } from 'components/role-guards';
+import EditPositionNameModal from 'components/Modal/EditPositionNameModal';
+import { format } from 'date-fns';
+
+const PositionNameAdmin = () => {
+  const { t } = useTranslation();
+
+  const [expandedRow, setExpandedRow] = useState<PositionName | null>(null);
+  const [filteredPositionNames, setFilteredPositionNames] = useState<PositionName[]>([]);
+  const [sortConfig, setSortConfig] = useState<{ key: keyof PositionName; direction: 'asc' | 'desc' } | null>(null);
+  const [page, setPage] = useState<number>(0);
+  const [rowsPerPage, setRowsPerPage] = useState<number>(10);
+  const [searchPositionName, setSearchPositionName] = useState<string>('');
+  const [showOnlyActive, setShowOnlyActive] = useState<boolean>(false);
+  const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
+
+  const { data: positionNames = [], isLoading: positionNamesLoading } = useGetPositionNamesQuery();
+
+  const isLoading = positionNamesLoading;
+
+  useEffect(() => {
+    if (positionNames.length > 0) {
+      setFilteredPositionNames(applyDefaultSorting(positionNames));
+    } else {
+      setFilteredPositionNames([]);
+    }
+  }, [positionNames]);
+
+  const applyDefaultSorting = (data: PositionName[]) => {
+    return [...data].sort((a, b) => {
+      return a.name.localeCompare(b.name, 'fi');
+    });
+  };
+
+  // UI callbacks
+
+  const handleOpenCreateModal = () => {
+    setCreateModalOpen(true);
+  };
+
+  const handleCloseCreateModal = () => {
+    setCreateModalOpen(false);
+  };
+
+  const handleRowToggle = (subject: PositionName) => {
+    setExpandedRow(expandedRow && expandedRow.id === subject.id ? null : subject);
+  };
+
+  const handleCloseDetails = () => {
+    setExpandedRow(null);
+  };
+
+  const handleChangePage = (_: unknown, newPage: number) => {
+    setPage(newPage);
+  };
+
+  const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setRowsPerPage(parseInt(event.target.value, 10));
+    setPage(0);
+  };
+
+  /* Sorting logic */
+  const handleSort = (key: keyof PositionName) => {
+    setSortConfig((prevConfig) => {
+      setPage(0);
+
+      if (!prevConfig || prevConfig.key !== key) {
+        // New key, sort the data based on the selected key and direction
+        const sortedData = [...filteredPositionNames].sort((a, b) => {
+          const valA = a[key] ?? '';
+          const valB = b[key] ?? '';
+
+          // Sort by date
+          if (key === 'validFrom' || key === 'validUntil') {
+            return (new Date(valA).getTime() || 0) - (new Date(valB).getTime() || 0);
+          }
+
+          // Sort by name
+          return valA.localeCompare(valB, 'fi');
+          //return valA < valB ? -1 : valA > valB ? 1 : 0;
+        });
+
+        setFilteredPositionNames(sortedData);
+        return { key, direction: 'asc' };
+      } else if (prevConfig.direction === 'asc') {
+        // Previous config is the same key and ascending, so sort in descending order
+        const sortedData = [...filteredPositionNames].sort((a, b) => {
+          const valA = a[key] ?? '';
+          const valB = b[key] ?? '';
+
+          // Sort by date
+          if (key === 'validFrom' || key === 'validUntil') {
+            return (new Date(valB).getTime() || 0) - (new Date(valA).getTime() || 0);
+          }
+
+          // Sort by name
+          return valB.localeCompare(valA, 'fi');
+          //return valA > valB ? -1 : valA < valB ? 1 : 0;
+        });
+
+        setFilteredPositionNames(sortedData);
+        return { key, direction: 'desc' };
+      } else {
+        // Default sorting
+        setFilteredPositionNames(applyDefaultSorting(filteredPositionNames));
+        return null;
+      }
+    });
+  };
+
+  // Search logic
+  const handleSearch = () => {
+    const filtered = positionNames.filter((p) => {
+      const matchesName = searchPositionName
+        ? p['name'].toLowerCase().includes(searchPositionName.toLowerCase())
+        : true;
+
+      const matchesActive =
+        !showOnlyActive ||
+        ((p.validFrom
+          ? new Date(new Date(p.validFrom).setHours(0, 0, 0, 0)) <= new Date(new Date().setHours(0, 0, 0, 0))
+          : true) &&
+          (p.validUntil
+            ? new Date(new Date().setHours(0, 0, 0, 0)) <= new Date(new Date(p.validUntil).setHours(0, 0, 0, 0))
+            : true));
+
+      return matchesName && matchesActive;
+    });
+
+    setFilteredPositionNames(applyDefaultSorting(filtered));
+    setPage(0);
+  };
+
+  const handleSearchReset = () => {
+    setSearchPositionName('');
+    setFilteredPositionNames(applyDefaultSorting(positionNames));
+    setShowOnlyActive(false);
+    setPage(0);
+  };
+
+  // Pagination logic
+  const paginatedPositionNames = filteredPositionNames.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
+
+  if (isLoading) {
+    return (
+      <Box display="flex" justifyContent="center" alignItems="center" height="100%">
+        <CircularProgress />
+      </Box>
+    );
+  }
+
+  return (
+    <Box>
+      <Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2} mb={2}>
+        {/* Search Controls */}
+        <Box display="flex" flexWrap="wrap" gap={2}>
+          <Box display="flex" gap={2}>
+            {/* Search by name */}
+            <TextField
+              label={t('admin_panel.search.byName')}
+              value={searchPositionName}
+              onChange={(e) => setSearchPositionName(e.target.value)}
+              sx={{ minWidth: '300px' }}
+            />
+
+            {/* Search only active */}
+            <FormControlLabel
+              control={
+                <Checkbox
+                  checked={showOnlyActive}
+                  onChange={(event) => setShowOnlyActive(event.target.checked)}
+                  color="primary"
+                />
+              }
+              label={t('admin_panel.position_name.show_only_active')}
+              sx={{ minWidth: '250px' }}
+            />
+
+            {/* Search and clear buttons */}
+            <Box display="flex" gap={2} flexWrap="wrap">
+              <Button variant="contained" onClick={handleSearch}>
+                {t('search_filter.search')}
+              </Button>
+
+              <Button variant="outlined" onClick={handleSearchReset}>
+                {t('search_filter.reset')}
+              </Button>
+            </Box>
+          </Box>
+        </Box>
+
+        <RequiresEditRole>
+          {/* Create new cost centre button */}
+          <Button
+            variant="contained"
+            onClick={handleOpenCreateModal}
+            sx={{
+              backgroundColor: '#223B7C',
+              color: 'white',
+              fontSize: '1.0rem',
+              padding: '20px',
+              height: '40px',
+              display: 'flex',
+              borderRadius: '25px 8px 8px 25px',
+              fontWeight: 'bold',
+              textTransform: 'none',
+            }}
+            startIcon={
+              <Box
+                component="span"
+                sx={{
+                  marginRight: '10px',
+                }}
+              >
+                +
+              </Box>
+            }
+          >
+            {t('admin_panel.position_name.create_new')}
+          </Button>
+        </RequiresEditRole>
+      </Box>
+
+      <TableContainer>
+        <Table sx={{ minWidth: 650 }}>
+          {/* Table Header */}
+          <TableHead sx={{ backgroundColor: '#223B7C', height: '30px' }}>
+            <TableRow>
+              {/* Name header */}
+              <TableCell
+                sx={{
+                  width: '55%',
+                  color: 'white',
+                  fontSize: '1.2rem',
+                  cursor: 'pointer',
+                  padding: '8px 16px',
+                }}
+                onClick={() => handleSort('name')}
+              >
+                <Box display="flex" alignItems="center" gap={2}>
+                  {t('admin_panel.position_name.name')}
+                  <Box sx={{ width: '16px', textAlign: 'center' }}>
+                    {sortConfig?.key === 'name' && (sortConfig.direction === 'asc' ? '🔼' : '🔽')}
+                  </Box>
+                </Box>
+              </TableCell>
+
+              {/* Position name valid from */}
+              <TableCell
+                sx={{ width: '20%', color: 'white', fontSize: '1.2rem', cursor: 'pointer', padding: '8px 16px' }}
+                onClick={() => handleSort('validFrom')}
+              >
+                <Box display="flex" alignItems="center" gap={2}>
+                  {t('admin_panel.position_name.valid_from')}
+                  <Box sx={{ width: '16px', textAlign: 'center' }}>
+                    {sortConfig?.key === 'validFrom' && (sortConfig.direction === 'asc' ? '🔼' : '🔽')}
+                  </Box>
+                </Box>
+              </TableCell>
+
+              {/* Position name valid until */}
+              <TableCell
+                sx={{ width: '20%', color: 'white', fontSize: '1.2rem', cursor: 'pointer', padding: '8px 16px' }}
+                onClick={() => handleSort('validUntil')}
+              >
+                <Box display="flex" alignItems="center" gap={2}>
+                  {t('admin_panel.position_name.valid_until')}
+                  <Box sx={{ width: '16px', textAlign: 'center' }}>
+                    {sortConfig?.key === 'validUntil' && (sortConfig.direction === 'asc' ? '🔼' : '🔽')}
+                  </Box>
+                </Box>
+              </TableCell>
+            </TableRow>
+          </TableHead>
+
+          {/* Table Body */}
+          <TableBody>
+            {paginatedPositionNames.map((row) => (
+              <React.Fragment key={row.id}>
+                <TableRow
+                  sx={{
+                    backgroundColor:
+                      row === expandedRow
+                        ? alpha('#223B7C', 0.5)
+                        : paginatedPositionNames.indexOf(row) % 2 === 0
+                          ? '#F9F9F9'
+                          : alpha('#223B7C', 0.2),
+                    cursor: 'pointer',
+                  }}
+                  onClick={() => handleRowToggle(row)}
+                >
+                  <TableCell sx={{ color: 'black', fontSize: '1rem' }}>{row.name}</TableCell>
+                  <TableCell sx={{ color: 'black', fontSize: '1rem' }}>
+                    {row.validFrom ? format(new Date(row.validFrom), 'd.M.yyyy') : ''}
+                  </TableCell>
+                  <TableCell sx={{ color: 'black', fontSize: '1rem' }}>
+                    {row.validUntil ? format(new Date(row.validUntil), 'd.M.yyyy') : ''}
+                  </TableCell>
+                </TableRow>
+              </React.Fragment>
+            ))}
+          </TableBody>
+        </Table>
+      </TableContainer>
+
+      {/* Pagination */}
+      <Box display="flex" justifyContent="center" mt={2}>
+        <TablePagination
+          component="div"
+          count={positionNames.length}
+          page={page}
+          onPageChange={handleChangePage}
+          rowsPerPage={rowsPerPage}
+          onRowsPerPageChange={handleChangeRowsPerPage}
+          labelRowsPerPage={t('table.rows_per_page')}
+        />
+      </Box>
+
+      <Grid2>
+        <Grid2 size={12}>
+          {expandedRow ? (
+            <PositionNameDetails
+              positionName={expandedRow}
+              doneEditingCallback={handleCloseDetails}
+              allPositionNames={positionNames}
+            />
+          ) : null}
+        </Grid2>
+      </Grid2>
+
+      {/* Create Position Name Modal */}
+      <EditPositionNameModal
+        open={createModalOpen}
+        handleClose={handleCloseCreateModal}
+        positionName={undefined}
+        allPositionNames={positionNames}
+        submitCallback={() => {
+          handleCloseDetails();
+          handleCloseCreateModal();
+        }}
+      />
+    </Box>
+  );
+};
+
+export default PositionNameAdmin;
diff --git a/frontend/src/components/AdminPanel/SubjectAdmin.tsx b/frontend/src/components/AdminPanel/SubjectAdmin.tsx
index 46d9b9c..88f118f 100644
--- a/frontend/src/components/AdminPanel/SubjectAdmin.tsx
+++ b/frontend/src/components/AdminPanel/SubjectAdmin.tsx
@@ -327,6 +327,8 @@ const SubjectAdmin = () => {
           ) : null}
         </Grid2>
       </Grid2>
+
+      {/* Create Subject Modal */}
       <EditSubjectModal
         open={createModalOpen}
         handleClose={handleCloseCreateModal}
diff --git a/frontend/src/components/Details/PositionNameDetails.tsx b/frontend/src/components/Details/PositionNameDetails.tsx
new file mode 100644
index 0000000..2df2297
--- /dev/null
+++ b/frontend/src/components/Details/PositionNameDetails.tsx
@@ -0,0 +1,277 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { PositionName } from 'models/PositionName';
+import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Typography, alpha } from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import Grid2 from '@mui/material/Grid2';
+import EditPositionNameModal from 'components/Modal/EditPositionNameModal';
+import RenderReadonlyTextField from './RenderReadonlyTextField';
+import { RequiresEditRole } from 'components/role-guards';
+import { useGetAdminChangelogsByObjectIdQuery } from 'redux/api-slices/functions/admin-changelog-api';
+import type { AdminChangeLogEntry } from 'models/AdminChangeLogEntry';
+import { format, parse, isValid } from 'date-fns';
+
+interface PositionNameDetailsProps {
+  positionName: PositionName;
+  allPositionNames: PositionName[];
+  doneEditingCallback: () => void;
+}
+
+const PositionNameDetails: React.FC<PositionNameDetailsProps> = ({
+  positionName,
+  allPositionNames,
+  doneEditingCallback,
+}) => {
+  const { t } = useTranslation();
+
+  const [editModalOpen, setEditModalOpen] = useState(false);
+
+  const handleOpenEditModal = () => {
+    setEditModalOpen(true);
+  };
+
+  const handleCloseEditModal = () => {
+    setEditModalOpen(false);
+  };
+
+  // Callback for when the edit modal is submitted
+  // Closes the modal and calls the doneEditingCallback function to close the detail view
+  const handleSubmitEditModal = () => {
+    setEditModalOpen(false);
+    doneEditingCallback();
+  };
+
+  // Formats the changelog entries if needed
+  const formatIfNeeded = (value: string, field: string) => {
+    if (field === 'CreatedPositionName' && !value) {
+      return '-';
+    }
+    if ((field === 'ValidFrom' || field === 'ValidUntil') && value) {
+      const parsed = parse(value, 'dd/MM/yyyy H.mm.ss', new Date());
+      if (!isValid(parsed)) {
+        return value;
+      }
+      return format(parsed, 'd.M.yyyy');
+    }
+    return value;
+  };
+
+  // Returns the correct edited field label
+  const getEditedFieldLabel = (field: string) => {
+    switch (field) {
+      case 'CreatedPositionName':
+        return t('change_logs.fields.CreatedPositionName');
+      case 'Name':
+        return t('admin_panel.position_name.name');
+      case 'ValidFrom':
+        return t('admin_panel.position_name.valid_from');
+      case 'ValidUntil':
+        return t('admin_panel.position_name.valid_until');
+      default:
+        return field;
+    }
+  };
+
+  const { data: positionNameChangelogs = [] /* isLoading: positionNameChangeLogsLoading */ } =
+    useGetAdminChangelogsByObjectIdQuery(positionName.id);
+
+  return (
+    <Box
+      sx={{
+        backgroundColor: '#FFFFFF',
+        borderRadius: '0px',
+        boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)',
+      }}
+    >
+      {
+        <Box sx={{ padding: 2, display: 'flex', justifyContent: 'right' }}>
+          <RequiresEditRole>
+            <Button
+              variant="contained"
+              onClick={handleOpenEditModal}
+              sx={{
+                backgroundColor: '#223B7C',
+                color: 'white',
+                fontSize: '1rem',
+                padding: '10px 20px',
+                height: '36px',
+                display: 'inline-flex',
+                borderRadius: '25px 8px 8px 25px',
+                fontWeight: 'bold',
+                textTransform: 'none',
+              }}
+              startIcon={
+                <Box
+                  component="span"
+                  sx={{
+                    marginRight: '8px',
+                  }}
+                >
+                  ✎
+                </Box>
+              }
+            >
+              {t('admin_panel.position_name.edit')}
+            </Button>
+          </RequiresEditRole>
+        </Box>
+      }
+      <Box
+        sx={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          p: 2,
+          height: 40,
+          borderBottom: '1px solid #ccc',
+          bgcolor: '#223B7C',
+          color: 'white',
+          borderTopLeftRadius: 1,
+          borderTopRightRadius: 1,
+        }}
+      >
+        <Typography
+          sx={{
+            color: 'white',
+            fontSize: '1.2rem',
+            textTransform: 'none',
+          }}
+        >
+          {t('admin_panel.position_name.details')}
+        </Typography>
+      </Box>
+
+      <Box padding={2}>
+        <Grid2 container spacing={2}>
+          <Grid2 size={6} px={2}>
+            <Typography component={'div'} sx={{ color: '#7f7f7f' }}>
+              {t('admin_panel.position_name.name')}
+            </Typography>
+            <RenderReadonlyTextField value={positionName.name} />
+          </Grid2>
+
+          {/* Position name timeframe */}
+          <Grid2 size={6} px={2}>
+            <Typography component={'div'} sx={{ color: '#7f7f7f' }}>
+              {t('admin_panel.position_name.time_frame')}
+            </Typography>
+            <RenderReadonlyTextField
+              value={
+                positionName.validFrom && positionName.validUntil
+                  ? `${format(new Date(positionName.validFrom), 'd.M.yyyy')} - ${format(new Date(positionName.validUntil), 'd.M.yyyy')}`
+                  : positionName.validFrom
+                    ? `${format(new Date(positionName.validFrom), 'd.M.yyyy')} -`
+                    : positionName.validUntil
+                      ? `- ${format(new Date(positionName.validUntil), 'd.M.yyyy')}`
+                      : ''
+              }
+            />
+          </Grid2>
+        </Grid2>
+
+        <Accordion sx={{ mt: 2, mb: 0 }}>
+          <AccordionSummary
+            expandIcon={<ExpandMoreIcon sx={{ color: 'white' }} />}
+            aria-controls="panel1a-content"
+            id="panel1a-header"
+            sx={{
+              backgroundColor: alpha('#223B7C', 1),
+              color: 'white',
+              minHeight: '45px',
+              '&.Mui-expanded': {
+                minHeight: '45px',
+              },
+              '& .MuiAccordionSummary-content': {
+                margin: 0,
+              },
+            }}
+          >
+            <Typography sx={{ color: 'white', fontSize: '1.0rem', fontWeight: 'bold', textTransform: 'none' }}>
+              {t('admin_panel.position_name.edit_history')}
+            </Typography>
+          </AccordionSummary>
+          <AccordionDetails
+            sx={{
+              padding: '16px',
+              backgroundColor: alpha('#fffff', 1),
+            }}
+          >
+            <Grid2 size={12}>
+              {positionNameChangelogs.length > 0 ? (
+                <Grid2 container spacing={0} sx={{ justifyContent: 'flex-start' }}>
+                  {/* Edited field */}
+                  <Grid2 size={2} sx={{ width: '20%' }}>
+                    <Typography component={'div'} sx={{ color: '#7f7f7f' }}>
+                      {t('change_logs.edited_field')}
+                    </Typography>
+                  </Grid2>
+
+                  {/* Old value */}
+                  <Grid2 size={3} sx={{ width: '20%' }}>
+                    <Typography component={'div'} sx={{ color: '#7f7f7f' }}>
+                      {t('change_logs.old_value')}
+                    </Typography>
+                  </Grid2>
+
+                  {/* New value */}
+                  <Grid2 size={3} sx={{ width: '20%' }}>
+                    <Typography component={'div'} sx={{ color: '#7f7f7f' }}>
+                      {t('change_logs.new_value')}
+                    </Typography>
+                  </Grid2>
+
+                  {/* Editor */}
+                  <Grid2 size={2} sx={{ width: '20%' }}>
+                    <Typography component={'div'} sx={{ color: '#7f7f7f' }}>
+                      {t('change_logs.editor')}
+                    </Typography>
+                  </Grid2>
+
+                  {/* Timestamp */}
+                  <Grid2 size={2} sx={{ width: '20%' }}>
+                    <Typography component={'div'} sx={{ color: '#7f7f7f' }}>
+                      {t('change_logs.timestamp')}
+                    </Typography>
+                  </Grid2>
+
+                  {/* Changelog entries */}
+                  {positionNameChangelogs.map((changelog: AdminChangeLogEntry) => (
+                    <React.Fragment key={changelog.id}>
+                      <Grid2 size={2} sx={{ width: '20%' }}>
+                        <RenderReadonlyTextField value={getEditedFieldLabel(changelog.editedField)} />
+                      </Grid2>
+                      <Grid2 size={3} sx={{ width: '20%' }}>
+                        <RenderReadonlyTextField value={formatIfNeeded(changelog.oldValue, changelog.editedField)} />
+                      </Grid2>
+                      <Grid2 size={3} sx={{ width: '20%' }}>
+                        <RenderReadonlyTextField value={formatIfNeeded(changelog.newValue, changelog.editedField)} />
+                      </Grid2>
+                      <Grid2 size={2} sx={{ width: '20%' }}>
+                        <RenderReadonlyTextField value={changelog.editor} />
+                      </Grid2>
+                      <Grid2 size={2} sx={{ width: '20%' }}>
+                        <RenderReadonlyTextField value={format(new Date(changelog.timestamp), 'dd.MM.yyyy HH:mm:ss')} />
+                      </Grid2>
+                    </React.Fragment>
+                  ))}
+                </Grid2>
+              ) : (
+                <RenderReadonlyTextField value={t('change_logs.no_logs_found')} />
+              )}
+            </Grid2>
+          </AccordionDetails>
+        </Accordion>
+      </Box>
+
+      {/* Edit Position Name Modal */}
+      <EditPositionNameModal
+        open={editModalOpen}
+        handleClose={handleCloseEditModal}
+        submitCallback={handleSubmitEditModal}
+        positionName={positionName}
+        allPositionNames={allPositionNames}
+      />
+    </Box>
+  );
+};
+export default PositionNameDetails;
diff --git a/frontend/src/components/Modal/EditPositionNameModal.tsx b/frontend/src/components/Modal/EditPositionNameModal.tsx
new file mode 100644
index 0000000..36861af
--- /dev/null
+++ b/frontend/src/components/Modal/EditPositionNameModal.tsx
@@ -0,0 +1,269 @@
+import React from 'react';
+import { Box, TextField, Button, Grid2, IconButton, Typography } from '@mui/material';
+
+import RenderReadonlyTextField from 'components/Details/RenderReadonlyTextField';
+
+import Modal from '@mui/material/Modal';
+import CloseIcon from '@mui/icons-material/Close';
+import { Form, Field } from 'react-final-form';
+import { useTranslation } from 'react-i18next';
+
+import type { PositionName } from 'models/PositionName';
+import { useUpdatePositionNameMutation } from 'redux/api-slices/functions/position-names-api';
+import { useCreatePositionNameMutation } from 'redux/api-slices/functions/position-names-api';
+
+interface EditPositionNameModalProps {
+  open: boolean;
+  handleClose: () => void;
+  submitCallback: () => void;
+  positionName: PositionName | undefined;
+  allPositionNames: PositionName[];
+}
+
+interface FormValues {
+  name: string;
+  validFrom?: string;
+  validUntil?: string;
+}
+
+const EditPositionNameModal: React.FC<EditPositionNameModalProps> = ({
+  open,
+  handleClose,
+  submitCallback,
+  positionName,
+  allPositionNames,
+}) => {
+  const isCreateDialog: boolean = positionName === undefined ? true : false;
+
+  const { t } = useTranslation();
+
+  const [updateSubject] = useUpdatePositionNameMutation();
+  const [createSubject] = useCreatePositionNameMutation();
+
+  // Formats the date for the input field
+  const formatDateForInput = (dateString?: string): string => {
+    if (!dateString) return '';
+
+    const date = new Date(dateString);
+    if (isNaN(date.getTime())) return '';
+
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+
+    return `${year}-${month}-${day}`;
+  };
+
+  const initialValues: Partial<PositionName> =
+    positionName !== undefined
+      ? {
+          name: positionName.name,
+          validFrom: formatDateForInput(positionName.validFrom),
+          validUntil: formatDateForInput(positionName.validUntil),
+        }
+      : {
+          name: '',
+          validFrom: '',
+          validUntil: '',
+        };
+
+  const validatePositionName = (value: string /* allValues: Record<string, any> */) => {
+    if (
+      value &&
+      allPositionNames.some((posName) => {
+        return posName.name === value.toLowerCase() && (isCreateDialog || (positionName && value != positionName.name));
+      })
+    )
+      return t('admin_panel.position_name.validation.duplicate_name');
+
+    return undefined;
+  };
+
+  // Checks whether the valid from date is before the valid until date
+  const validateValidUntil = (value: string, allValues: FormValues) => {
+    if (allValues.validFrom && value) {
+      const validFromDate = new Date(allValues.validFrom);
+      const validUntilDate = new Date(value);
+      if (validUntilDate <= validFromDate) {
+        return t('admin_panel.position_name.validation.invalid_date');
+      }
+    }
+    return undefined;
+  };
+
+  const onSubmit = async (values: FormValues) => {
+    if (!positionName || !positionName.id) {
+      // create new position name
+      try {
+        const subjectData: Partial<PositionName> = {
+          name: values.name.toLowerCase(),
+          validFrom: values.validFrom ? new Date(values.validFrom).toISOString().split('T')[0] : undefined,
+          validUntil: values.validUntil ? new Date(values.validUntil).toISOString().split('T')[0] : undefined,
+        };
+        createSubject(subjectData);
+        submitCallback();
+        //handleClose();
+        return;
+      } catch (error) {
+        console.error('Failed to create position name:', error);
+      }
+    } else {
+      // edit existing position name
+      try {
+        const updateData = {
+          name: values.name.toLowerCase(),
+          validFrom: values.validFrom ? new Date(values.validFrom).toISOString().split('T')[0] : undefined,
+          validUntil: values.validUntil ? new Date(values.validUntil).toISOString().split('T')[0] : undefined,
+        };
+        updateSubject({ id: positionName.id, data: updateData });
+        submitCallback();
+        //handleClose();
+      } catch (error) {
+        console.error('Failed to update position name:', error);
+      }
+    }
+  };
+
+  return (
+    <Modal open={open} onClose={handleClose}>
+      <Box
+        sx={{
+          position: 'absolute',
+          top: '50%',
+          left: '50%',
+          transform: 'translate(-50%, -50%)',
+          width: '100%',
+          maxWidth: '800px',
+          maxHeight: '90vh',
+          overflow: 'auto',
+          bgcolor: 'background.paper',
+          boxShadow: 24,
+          p: 0,
+          borderRadius: 1,
+        }}
+      >
+        {/* Header */}
+        <Box
+          sx={{
+            display: 'flex',
+            justifyContent: 'space-between',
+            alignItems: 'center',
+            p: 2,
+            height: 50,
+            bgcolor: '#223B7C',
+            color: 'white',
+            borderTopLeftRadius: 1,
+            borderTopRightRadius: 1,
+          }}
+        >
+          <Typography variant="h6">
+            {isCreateDialog ? t('admin_panel.position_name.create_new') : t('admin_panel.position_name.edit')}
+          </Typography>
+          <IconButton onClick={handleClose} sx={{ color: 'white' }}>
+            <CloseIcon />
+          </IconButton>
+        </Box>
+
+        {/* Content */}
+        <Box sx={{ p: 4, pt: 0 }}>
+          <Form
+            onSubmit={onSubmit}
+            initialValues={initialValues}
+            render={({ handleSubmit, submitting, pristine, hasValidationErrors }) => (
+              <form onSubmit={handleSubmit}>
+                <Grid2 container spacing={2} size={12}>
+                  {/* Subtitle */}
+                  <Box sx={{ px: 2, mt: 2 }}>
+                    <Typography variant="h5" sx={{ fontWeight: 'bold', color: '#223b7c' }}>
+                      {t('admin_panel.position_name.details')}
+                    </Typography>
+                  </Box>
+                  {isCreateDialog || <RenderReadonlyTextField value={t('admin_panel.position_name.edit_warning')} />}
+                  {/* Name */}
+                  <Grid2 size={12}>
+                    <Field name="name" validate={validatePositionName}>
+                      {({ input, meta }) => {
+                        return (
+                          <TextField
+                            {...input}
+                            fullWidth
+                            required
+                            margin="normal"
+                            placeholder={initialValues.name}
+                            label={t('admin_panel.position_name.name')}
+                            error={meta.error && meta.touched}
+                            helperText={meta.touched && meta.error}
+                            slotProps={{
+                              inputLabel: {
+                                shrink: true,
+                              },
+                            }}
+                          />
+                        );
+                      }}
+                    </Field>
+                  </Grid2>
+                </Grid2>
+
+                {/* Valid from */}
+                <Grid2 size={12} sx={{ display: 'flex', gap: 2 }}>
+                  <Field name="validFrom">
+                    {({ input, meta }) => (
+                      <TextField
+                        {...input}
+                        margin="normal"
+                        label={t('admin_panel.position_name.valid_from')}
+                        type="date"
+                        slotProps={{ inputLabel: { shrink: true } }}
+                        sx={{ width: '360px' }}
+                        error={meta.error && meta.touched}
+                        helperText={meta.touched && meta.error}
+                      />
+                    )}
+                  </Field>
+
+                  {/* Valid until */}
+
+                  <Field
+                    name="validUntil"
+                    validate={(value, allValues) => validateValidUntil(value, allValues as FormValues)}
+                  >
+                    {({ input, meta }) => (
+                      <TextField
+                        {...input}
+                        margin="normal"
+                        label={t('admin_panel.position_name.valid_until')}
+                        type="date"
+                        slotProps={{ inputLabel: { shrink: true } }}
+                        sx={{ width: '360px' }}
+                        error={meta.error && meta.touched}
+                        helperText={meta.touched && meta.error}
+                      />
+                    )}
+                  </Field>
+                </Grid2>
+
+                {/* Buttons */}
+                <Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
+                  <Button variant="outlined" onClick={handleClose}>
+                    {t('edit_position.cancel')}
+                  </Button>
+                  <Button
+                    type="submit"
+                    variant="contained"
+                    sx={{ backgroundColor: '#223B7C', color: 'white', ml: 2 }}
+                    disabled={submitting || pristine || hasValidationErrors}
+                  >
+                    {t('edit_position.save')}
+                  </Button>
+                </Box>
+              </form>
+            )}
+          />
+        </Box>
+      </Box>
+    </Modal>
+  );
+};
+
+export default EditPositionNameModal;
diff --git a/frontend/src/components/Modal/EditSubjectModal.tsx b/frontend/src/components/Modal/EditSubjectModal.tsx
index 0c1b5f6..f57a822 100644
--- a/frontend/src/components/Modal/EditSubjectModal.tsx
+++ b/frontend/src/components/Modal/EditSubjectModal.tsx
@@ -65,7 +65,9 @@ const EditSubjectModal: React.FC<EditSubjectModalProps> = ({
   };
 
   const onSubmit = async (values: FormValues) => {
-    if (!position || !position.id) {
+    if (isCreateDialog) {
+      //if (!position || !position.id) {
+      // Create new subject
       try {
         const subjectData: Partial<TeacherSubject> = {
           subjectName: values.subjectName.toLowerCase(),
@@ -78,12 +80,13 @@ const EditSubjectModal: React.FC<EditSubjectModalProps> = ({
         console.error('Failed to create subject:', error);
       }
     } else {
+      // Update existing subject
       try {
         const updateData = {
           subjectName: values.subjectName.toLowerCase(),
           active: values.active,
         };
-        updateSubject({ id: position.id, subject: updateData });
+        updateSubject({ id: position!.id, subject: updateData });
         submitCallback();
       } catch (error) {
         console.error('Failed to update subject:', error);
@@ -147,9 +150,8 @@ const EditSubjectModal: React.FC<EditSubjectModalProps> = ({
                       {t('admin_panel.teacher_subjects.details')}
                     </Typography>
                   </Box>
-                  <RenderReadonlyTextField
-                    value={isCreateDialog ? '' : t('admin_panel.teacher_subjects.editwarning')}
-                  />
+                  {isCreateDialog || <RenderReadonlyTextField value={t('admin_panel.teacher_subjects.editwarning')} />}
+
                   {/* Name */}
                   <Grid2 size={12}>
                     <Field name="subjectName" validate={validateSubjectName}>
@@ -157,6 +159,7 @@ const EditSubjectModal: React.FC<EditSubjectModalProps> = ({
                         return (
                           <TextField
                             {...input}
+                            required
                             fullWidth
                             margin="normal"
                             placeholder={initialValues.subjectName}
@@ -172,14 +175,12 @@ const EditSubjectModal: React.FC<EditSubjectModalProps> = ({
                         );
                       }}
                     </Field>
-                    {/* //</Grid2> */}
 
                     {/* Active checkbox */}
-                    {/* <Grid2 size={12}> */}
                     <Field name="active" type="checkbox">
                       {({ input }) => (
                         <FormControlLabel
-                          control={<Checkbox {...input} /* checked={Boolean(input.value)} */ />}
+                          control={<Checkbox {...input} />}
                           label={t('admin_panel.teacher_subjects.active')}
                         />
                       )}
diff --git a/frontend/src/redux/api-slices/functions/costcentre-api.ts b/frontend/src/redux/api-slices/functions/costcentre-api.ts
index ecdc9fc..9ee2585 100644
--- a/frontend/src/redux/api-slices/functions/costcentre-api.ts
+++ b/frontend/src/redux/api-slices/functions/costcentre-api.ts
@@ -30,7 +30,10 @@ const costcentreTreesApi = baseApi.injectEndpoints({
           'Content-Type': 'application/json',
         },
       }),
-      invalidatesTags: (_result, _error, { id }) => [{ type: 'CostCenters', id }],
+      invalidatesTags: (_result, _error, { id }) => [
+        { type: 'CostCenters', id },
+        { type: 'AdminChangeLogs', id },
+      ],
     }),
   }),
 });
diff --git a/frontend/src/redux/api-slices/functions/position-names-api.ts b/frontend/src/redux/api-slices/functions/position-names-api.ts
index 43b8dc7..d58dd92 100644
--- a/frontend/src/redux/api-slices/functions/position-names-api.ts
+++ b/frontend/src/redux/api-slices/functions/position-names-api.ts
@@ -15,7 +15,31 @@ const PositionNamesApi = baseApi.injectEndpoints({
       query: (id) => `/positionnames/${id}`,
       providesTags: (_result, _error, id) => [{ type: 'PositionNames', id }],
     }),
-  }),
+    updatePositionName: build.mutation<PositionName, { id: string; data: Partial<PositionName> }>({
+      query: ({ id, data }) => ({
+        url: `/positionnames/${id}`,
+        method: 'PUT',
+        body: data,
+      }),
+      invalidatesTags: (_result, _error, { id }) => [
+        { type: 'PositionNames', id },
+        { type: 'AdminChangeLogs', id },
+      ],
+    }),
+    createPositionName: build.mutation<PositionName, Partial<PositionName>>({
+      query: (data) => ({
+        url: '/positionnames',
+        method: 'POST',
+        body: data,
+      }),
+      invalidatesTags: [{ type: 'PositionNames', id: 'LIST' }],
+    }),
+  }), // end endpoints
 });
 
-export const { useGetPositionNamesQuery, useGetPositionNameByIdQuery } = PositionNamesApi;
+export const {
+  useGetPositionNamesQuery,
+  useGetPositionNameByIdQuery,
+  useUpdatePositionNameMutation,
+  useCreatePositionNameMutation,
+} = PositionNamesApi;
diff --git a/frontend/src/redux/api-slices/functions/teachersubject-api.ts b/frontend/src/redux/api-slices/functions/teachersubject-api.ts
index 15a1700..fff7cf7 100644
--- a/frontend/src/redux/api-slices/functions/teachersubject-api.ts
+++ b/frontend/src/redux/api-slices/functions/teachersubject-api.ts
@@ -19,7 +19,10 @@ const TeacherSubjectsApi = baseApi.injectEndpoints({
         method: 'PUT',
         body: subject,
       }),
-      invalidatesTags: (_result, _error, { id }) => [{ type: 'TeacherSubjects', id }],
+      invalidatesTags: (_result, _error, { id }) => [
+        { type: 'TeacherSubjects', id },
+        { type: 'AdminChangeLogs', id },
+      ],
     }),
     createTeacherSubject: build.mutation<TeacherSubject, Partial<TeacherSubject>>({
       query: (subject) => ({


Reviews:
user: login: p-kyllonen
body: Laitoin koodiin muutaman korjaustoiveen. Pikkujuttuja vaan, muuten tuntu toimivan.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T08:30:24Z
user: login: af-tuni
body: Lisänä Peetun kommentteihin, lisää vielä translaatioiden titlet ruotsifiluun. Vaikka sinne ei oikeaa käännöstä vielä tule, niin on aikasemmin laitettu nuo käännöksien titlet sinne ylös kuitenkin. Muuten oikein hyvältä näyttää ja toimii.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T12:45:27Z
user: login: hwps
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:32:20Z
user: login: hwps
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:33:23Z
user: login: hwps
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:34:19Z
user: login: af-tuni
body: Vielä muutama console.log jäänyt kummittelemaan EditPositionNameModaliin. Muuten näyttäis olevan fixattu mitä pitää. Eli noi console logit pois niin approved mun puolesta.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T16:39:53Z
user: login: p-kyllonen
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T15:40:11Z
user: login: af-tuni
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T16:50:45Z

Review comments:
diff_hunk: @@ -0,0 +1,277 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { PositionName } from 'models/PositionName';
+import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Typography, alpha } from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import Grid2 from '@mui/material/Grid2';
+import EditPositionNameModal from 'components/Modal/EditPositionNameModal';
+import RenderReadonlyTextField from './RenderReadonlyTextField';
+import { RequiresEditRole } from 'components/role-guards';
+import { useGetAdminChangelogsByObjectIdQuery } from 'redux/api-slices/functions/admin-changelog-api';
+import type { AdminChangeLogEntry } from 'models/AdminChangeLogEntry';
+import { format, parse, isValid } from 'date-fns';
+
+interface PositionNameDetailsProps {
+  positionName: PositionName;
+  allPositionNames: PositionName[];
+  doneEditingCallback: () => void;
+}
+
+const PositionNameDetails: React.FC<PositionNameDetailsProps> = ({
+  positionName,
+  allPositionNames,
+  doneEditingCallback,
+}) => {
+  const { t } = useTranslation();
+
+  const [editModalOpen, setEditModalOpen] = useState(false);
+
+  const handleOpenEditModal = () => {
+    setEditModalOpen(true);
+  };
+
+  const handleCloseEditModal = () => {
+    setEditModalOpen(false);
+  };
+
+  // Callback for when the edit modal is submitted
+  // Closes the modal and calls the doneEditingCallback function to close the detail view
+  const handleSubmitEditModal = () => {
+    setEditModalOpen(false);
+    doneEditingCallback();
+  };
+
+  // Formats the changelog entries if needed
+  const formatIfNeeded = (value: string, field: string) => {
+    if (field === 'CreatedPositionName' && !value) {
+      return '-';
+    }
+    if ((field === 'ValidFrom' || field === 'ValidUntil') && value) {
+      const parsed = parse(value, 'dd/MM/yyyy H.mm.ss', new Date());
+      if (!isValid(parsed)) {
+        return value;
+      }
+      return format(parsed, 'd.M.yyyy');
+    }
+    return value;
+  };
+
+  // Returns the correct edited field label
+  const getEditedFieldLabel = (field: string) => {
+    switch (field) {
+      case 'CreatedPositionName':
+        return t('change_logs.fields.CreatedPositionName');
+      case 'Name':
+        return t('admin_panel.position_name.name');
+      case 'ValidFrom':
+        return t('admin_panel.position_name.valid_from');
+      case 'ValidUntil':
+        return t('admin_panel.position_name.valid_until');
+      default:
+        return field;
+    }
+  };
+
+  const { data: positionNameChangelogs = [] /* isLoading: positionNameChangeLogsLoading */ } =
+    useGetAdminChangelogsByObjectIdQuery(positionName.id);
+
+  return (
+    <Box
+      sx={{
+        backgroundColor: '#FFFFFF',
+        borderRadius: '0px',
+        boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)',
+      }}
+    >
+      {
+        <Box sx={{ padding: 2, display: 'flex', justifyContent: 'right' }}>
+          <RequiresEditRole>
+            <Button
+              variant="contained"
+              onClick={handleOpenEditModal}
+              sx={{
+                backgroundColor: '#223B7C',
+                color: 'white',
+                fontSize: '1rem',
+                padding: '10px 20px',
+                height: '36px',
+                display: 'inline-flex',
+                borderRadius: '25px 8px 8px 25px',
+                fontWeight: 'bold',
+                textTransform: 'none',
+              }}
+              startIcon={
+                <Box
+                  component="span"
+                  sx={{
+                    marginRight: '8px',
+                  }}
+                >
+                  ✎
+                </Box>
+              }
+            >
+              {t('admin_panel.position_name.edit')}
+            </Button>
+          </RequiresEditRole>
+        </Box>
+      }
+      <Box
+        sx={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          p: 2,
+          height: 40,
+          borderBottom: '1px solid #ccc',
+          bgcolor: '#223B7C',
+          color: 'white',
+          borderTopLeftRadius: 1,
+          borderTopRightRadius: 1,
+        }}
+      >
+        <Typography
+          sx={{
+            color: 'white',
+            fontSize: '1.2rem',
+            textTransform: 'none',
+          }}
+        >
+          {t('admin_panel.position_name.details')}
+        </Typography>
+      </Box>
+
+      <Box padding={2}>
+        <Grid2 container spacing={2} size={16}>
path: frontend/src/components/Details/PositionNameDetails.tsx
user: login: p-kyllonen
body: 16->12

16 rikkoo tuon boxin ja tulee sivuscrolli. 12 hyvä.
created_at: 2025-04-22T08:22:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,370 @@
+import React, { useState, useEffect } from 'react';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableContainer,
+  TableHead,
+  TableRow,
+  Box,
+  CircularProgress,
+  alpha,
+  TablePagination,
+  TextField,
+  Button,
+  Grid2,
+  Checkbox,
+  FormControlLabel,
+} from '@mui/material';
+
+import { useTranslation } from 'react-i18next';
+
+import type { PositionName } from 'models/PositionName';
+
+import { useGetPositionNamesQuery } from 'redux/api-slices/functions/position-names-api';
+import PositionNameDetails from 'components/Details/PositionNameDetails';
+import { RequiresEditRole } from 'components/role-guards';
+import EditPositionNameModal from 'components/Modal/EditPositionNameModal';
+import { format } from 'date-fns';
+
+const PositionNameAdmin = () => {
+  const { t } = useTranslation();
+
+  const [expandedRow, setExpandedRow] = useState<PositionName | null>(null);
+  const [filteredPositionNames, setFilteredPositionNames] = useState<PositionName[]>([]);
+  const [sortConfig, setSortConfig] = useState<{ key: keyof PositionName; direction: 'asc' | 'desc' } | null>(null);
+  const [page, setPage] = useState<number>(0);
+  const [rowsPerPage, setRowsPerPage] = useState<number>(10);
+  const [searchPositionName, setSearchPositionName] = useState<string>('');
+  const [showOnlyActive, setShowOnlyActive] = useState<boolean>(false);
+  const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
+
+  const { data: positionNames = [], isLoading: positionNamesLoading } = useGetPositionNamesQuery();
+
+  const isLoading = positionNamesLoading;
+
+  useEffect(() => {
+    if (positionNames.length > 0) {
+      setFilteredPositionNames(applyDefaultSorting(positionNames));
+    } else {
+      setFilteredPositionNames([]);
+    }
+  }, [positionNames]);
+
+  const applyDefaultSorting = (data: PositionName[]) => {
+    return [...data].sort((a, b) => {
+      return a.name.localeCompare(b.name, 'fi');
+    });
+  };
+
+  // UI callbacks
+
+  const handleOpenCreateModal = () => {
+    setCreateModalOpen(true);
+  };
+
+  const handleCloseCreateModal = () => {
+    setCreateModalOpen(false);
+  };
+
+  const handleRowToggle = (subject: PositionName) => {
+    setExpandedRow(expandedRow && expandedRow.id === subject.id ? null : subject);
+  };
+
+  const handleCloseDetails = () => {
+    setExpandedRow(null);
+  };
+
+  const handleChangePage = (_: unknown, newPage: number) => {
+    setPage(newPage);
+  };
+
+  const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setRowsPerPage(parseInt(event.target.value, 10));
+    setPage(0);
+  };
+
+  /* Sorting logic */
+  const handleSort = (key: keyof PositionName) => {
+    setSortConfig((prevConfig) => {
+      setPage(0);
+
+      if (!prevConfig || prevConfig.key !== key) {
+        // New key, sort the data based on the selected key and direction
+        const sortedData = [...filteredPositionNames].sort((a, b) => {
+          const valA = a[key] ?? '';
+          const valB = b[key] ?? '';
+
+          // Sort by date
+          if (key === 'validFrom' || key === 'validUntil') {
+            return (new Date(valA).getTime() || 0) - (new Date(valB).getTime() || 0);
+          }
+
+          // Sort by name
+          return valA.localeCompare(valB, 'fi');
+          //return valA < valB ? -1 : valA > valB ? 1 : 0;
+        });
+
+        setFilteredPositionNames(sortedData);
+        return { key, direction: 'asc' };
+      } else if (prevConfig.direction === 'asc') {
+        // Previous config is the same key and ascending, so sort in descending order
+        const sortedData = [...filteredPositionNames].sort((a, b) => {
+          const valA = a[key] ?? '';
+          const valB = b[key] ?? '';
+
+          // Sort by date
+          if (key === 'validFrom' || key === 'validUntil') {
+            return (new Date(valB).getTime() || 0) - (new Date(valA).getTime() || 0);
+          }
+
+          // Sort by name
+          return valB.localeCompare(valA, 'fi');
+          //return valA > valB ? -1 : valA < valB ? 1 : 0;
+        });
+
+        setFilteredPositionNames(sortedData);
+        return { key, direction: 'desc' };
+      } else {
+        // Default sorting
+        setFilteredPositionNames(applyDefaultSorting(filteredPositionNames));
+        return null;
+      }
+    });
+  };
+
+  // Search logic
+  const handleSearch = () => {
+    const filtered = positionNames.filter((p) => {
+      const matchesName = searchPositionName
+        ? p['name'].toLowerCase().includes(searchPositionName.toLowerCase())
+        : true;
+
+      const matchesActive =
+        !showOnlyActive ||
+        ((p.validFrom
+          ? new Date(new Date(p.validFrom).setHours(0, 0, 0, 0)) <= new Date(new Date().setHours(0, 0, 0, 0))
+          : true) &&
+          (p.validUntil
+            ? new Date(new Date().setHours(0, 0, 0, 0)) <= new Date(new Date(p.validUntil).setHours(0, 0, 0, 0))
+            : true));
+
+      return matchesName && matchesActive;
+    });
+
+    setFilteredPositionNames(applyDefaultSorting(filtered));
+    setPage(0);
+  };
+
+  const handleSearchReset = () => {
+    setSearchPositionName('');
+    setFilteredPositionNames(applyDefaultSorting(positionNames));
+    setShowOnlyActive(false);
+    setPage(0);
+  };
+
+  // Pagination logic
+  const paginatedPositionNames = filteredPositionNames.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
+
+  if (isLoading) {
+    return (
+      <Box display="flex" justifyContent="center" alignItems="center" height="100%">
+        <CircularProgress />
+      </Box>
+    );
+  }
+
+  return (
+    <Box>
+      <Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2} mb={2}>
+        {/* Search Controls */}
+        <Box display="flex" flexWrap="wrap" gap={2}>
+          <Box display="flex" gap={2}>
+            {/* Search by name */}
+            <TextField
+              label={t('admin_panel.search.byName')}
+              value={searchPositionName}
+              onChange={(e) => setSearchPositionName(e.target.value)}
+              sx={{ minWidth: '300px' }}
+            />
+
+            {/* Search only active */}
+            <FormControlLabel
+              control={
+                <Checkbox
+                  checked={showOnlyActive}
+                  onChange={(event) => setShowOnlyActive(event.target.checked)}
+                  color="primary"
+                />
+              }
+              label={t('admin_panel.position_name.show_only_active')}
+              sx={{ minWidth: '250px' }}
+            />
+
+            {/* Search and clear buttons */}
+            <Box display="flex" gap={2} flexWrap="wrap">
+              <Button variant="contained" onClick={handleSearch}>
+                {t('search_filter.search')}
+              </Button>
+
+              <Button variant="outlined" onClick={handleSearchReset}>
+                {t('search_filter.reset')}
+              </Button>
+            </Box>
+          </Box>
+        </Box>
+
+        <RequiresEditRole>
+          {/* Create new cost centre button */}
+          <Button
+            variant="contained"
+            onClick={handleOpenCreateModal}
+            sx={{
+              backgroundColor: '#223B7C',
+              color: 'white',
+              fontSize: '1.0rem',
+              padding: '20px',
+              height: '40px',
+              display: 'flex',
+              borderRadius: '25px 8px 8px 25px',
+              fontWeight: 'bold',
+              textTransform: 'none',
+            }}
+            startIcon={
+              <Box
+                component="span"
+                sx={{
+                  marginRight: '10px',
+                }}
+              >
+                +
+              </Box>
+            }
+          >
+            {t('admin_panel.position_name.create_new')}
+          </Button>
+        </RequiresEditRole>
+      </Box>
+
+      <TableContainer>
+        <Table sx={{ minWidth: 650 }}>
+          {/* Table Header */}
+          <TableHead sx={{ backgroundColor: '#223B7C', height: '30px' }}>
+            <TableRow>
+              {/* Name header */}
+              <TableCell
+                sx={{
path: frontend/src/components/AdminPanel/PositionNameAdmin.tsx
user: login: p-kyllonen
body: Voisi kans lisätä      width: '55%',
Niin olis pvm sarakkeet samassa kohtaa, kun kustannuspaikkojen hallintapaneelissa
created_at: 2025-04-22T08:24:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,272 @@
+import React from 'react';
+import { Box, TextField, Button, Grid2, IconButton, Typography } from '@mui/material';
+
+import RenderReadonlyTextField from 'components/Details/RenderReadonlyTextField';
+
+import Modal from '@mui/material/Modal';
+import CloseIcon from '@mui/icons-material/Close';
+import { Form, Field } from 'react-final-form';
+import { useTranslation } from 'react-i18next';
+
+import type { PositionName } from 'models/PositionName';
+import { useUpdatePositionNameMutation } from 'redux/api-slices/functions/position-names-api';
+import { useCreatePositionNameMutation } from 'redux/api-slices/functions/position-names-api';
+
+interface EditPositionNameModalProps {
+  open: boolean;
+  handleClose: () => void;
+  submitCallback: () => void;
+  positionName: PositionName | undefined;
+  allPositionNames: PositionName[];
+}
+
+interface FormValues {
+  name: string;
+  validFrom?: string;
+  validUntil?: string;
+}
+
+const EditPositionNameModal: React.FC<EditPositionNameModalProps> = ({
+  open,
+  handleClose,
+  submitCallback,
+  positionName,
+  allPositionNames,
+}) => {
+  const isCreateDialog: boolean = positionName === undefined ? true : false;
+
+  const { t } = useTranslation();
+
+  const [updateSubject] = useUpdatePositionNameMutation();
+  const [createSubject] = useCreatePositionNameMutation();
+
+  // Formats the date for the input field
+  const formatDateForInput = (dateString?: string): string => {
+    if (!dateString) return '';
+
+    const date = new Date(dateString);
+    if (isNaN(date.getTime())) return '';
+
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+
+    return `${year}-${month}-${day}`;
+  };
+
+  const initialValues: Partial<PositionName> =
+    positionName !== undefined
+      ? {
+          name: positionName.name,
+          validFrom: formatDateForInput(positionName.validFrom),
+          validUntil: formatDateForInput(positionName.validUntil),
+        }
+      : {
+          name: '',
+          validFrom: '',
+          validUntil: '',
+        };
+
+  const validatePositionName = (value: string /* allValues: Record<string, any> */) => {
+    if (
+      value &&
+      allPositionNames.some((posName) => {
+        return posName.name === value.toLowerCase() && (isCreateDialog || (positionName && value != positionName.name));
+      })
+    )
+      return t('admin_panel.position_name.validation.duplicate_name');
+
+    return undefined;
+  };
+
+  // Checks whether the valid from date is before the valid until date
+  const validateValidUntil = (value: string, allValues: FormValues) => {
+    if (allValues.validFrom && value) {
+      const validFromDate = new Date(allValues.validFrom);
+      const validUntilDate = new Date(value);
+      if (validUntilDate <= validFromDate) {
+        return t('admin_panel.position_name.validation.invalid_date');
+      }
+    }
+    return undefined;
+  };
+
+  const onSubmit = async (values: FormValues) => {
+    console.log(values);
+    if (!positionName || !positionName.id) {
+      // create new position name
+      try {
+        const subjectData: Partial<PositionName> = {
+          name: values.name.toLowerCase(),
+          validFrom: values.validFrom ? new Date(values.validFrom).toISOString().split('T')[0] : undefined,
+          validUntil: values.validUntil ? new Date(values.validUntil).toISOString().split('T')[0] : undefined,
+        };
+        console.log('Creating new position name!');
+        createSubject(subjectData);
+        submitCallback();
+        //handleClose();
+        return;
+      } catch (error) {
+        console.error('Failed to create position name:', error);
+      }
+    } else {
+      // edit existing position name
+      try {
+        const updateData = {
+          name: values.name.toLowerCase(),
+          validFrom: values.validFrom ? new Date(values.validFrom).toISOString().split('T')[0] : undefined,
+          validUntil: values.validUntil ? new Date(values.validUntil).toISOString().split('T')[0] : undefined,
+        };
+        console.log(updateData);
+        updateSubject({ id: positionName.id, data: updateData });
+        submitCallback();
+        //handleClose();
+      } catch (error) {
+        console.error('Failed to update position name:', error);
+      }
+    }
+  };
+
+  return (
+    <Modal open={open} onClose={handleClose}>
+      <Box
+        sx={{
+          position: 'absolute',
+          top: '50%',
+          left: '50%',
+          transform: 'translate(-50%, -50%)',
+          width: '100%',
+          maxWidth: '800px',
+          maxHeight: '90vh',
+          overflow: 'auto',
+          bgcolor: 'background.paper',
+          boxShadow: 24,
+          p: 0,
+          borderRadius: 1,
+        }}
+      >
+        {/* Header */}
+        <Box
+          sx={{
+            display: 'flex',
+            justifyContent: 'space-between',
+            alignItems: 'center',
+            p: 2,
+            height: 50,
+            bgcolor: '#223B7C',
+            color: 'white',
+            borderTopLeftRadius: 1,
+            borderTopRightRadius: 1,
+          }}
+        >
+          <Typography variant="h6">
+            {isCreateDialog ? t('admin_panel.position_name.create_new') : t('admin_panel.position_name.edit')}
+          </Typography>
+          <IconButton onClick={handleClose} sx={{ color: 'white' }}>
+            <CloseIcon />
+          </IconButton>
+        </Box>
+
+        {/* Content */}
+        <Box sx={{ p: 4, pt: 0 }}>
+          <Form
+            onSubmit={onSubmit}
+            initialValues={initialValues}
+            render={({ handleSubmit, submitting, pristine, hasValidationErrors }) => (
+              <form onSubmit={handleSubmit}>
+                <Grid2 container spacing={2} size={12}>
+                  {/* Subtitle */}
+                  <Box sx={{ px: 2, mt: 2 }}>
+                    <Typography variant="h5" sx={{ fontWeight: 'bold', color: '#223b7c' }}>
+                      {t('admin_panel.position_name.details')}
+                    </Typography>
+                  </Box>
+                  {isCreateDialog || <RenderReadonlyTextField value={t('admin_panel.position_name.edit_warning')} />}
+                  {/* Name */}
+                  <Grid2 size={12}>
+                    <Field name="name" validate={validatePositionName}>
+                      {({ input, meta }) => {
+                        //console.log(input)
+                        return (
+                          <TextField
+                            {...input}
+                            fullWidth
path: frontend/src/components/Modal/EditPositionNameModal.tsx
user: login: p-kyllonen
body: Tänne, jos lisäisi required niin ei antaisi submittaa ilman nimeä.
Ja tosta pari riviä ylempää voisi poistaa console login kans.
created_at: 2025-04-22T08:26:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,277 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { PositionName } from 'models/PositionName';
+import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Typography, alpha } from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import Grid2 from '@mui/material/Grid2';
+import EditPositionNameModal from 'components/Modal/EditPositionNameModal';
+import RenderReadonlyTextField from './RenderReadonlyTextField';
+import { RequiresEditRole } from 'components/role-guards';
+import { useGetAdminChangelogsByObjectIdQuery } from 'redux/api-slices/functions/admin-changelog-api';
+import type { AdminChangeLogEntry } from 'models/AdminChangeLogEntry';
+import { format, parse, isValid } from 'date-fns';
+
+interface PositionNameDetailsProps {
+  positionName: PositionName;
+  allPositionNames: PositionName[];
+  doneEditingCallback: () => void;
+}
+
+const PositionNameDetails: React.FC<PositionNameDetailsProps> = ({
+  positionName,
+  allPositionNames,
+  doneEditingCallback,
+}) => {
+  const { t } = useTranslation();
+
+  const [editModalOpen, setEditModalOpen] = useState(false);
+
+  const handleOpenEditModal = () => {
+    setEditModalOpen(true);
+  };
+
+  const handleCloseEditModal = () => {
+    setEditModalOpen(false);
+  };
+
+  // Callback for when the edit modal is submitted
+  // Closes the modal and calls the doneEditingCallback function to close the detail view
+  const handleSubmitEditModal = () => {
+    setEditModalOpen(false);
+    doneEditingCallback();
+  };
+
+  // Formats the changelog entries if needed
+  const formatIfNeeded = (value: string, field: string) => {
+    if (field === 'CreatedPositionName' && !value) {
+      return '-';
+    }
+    if ((field === 'ValidFrom' || field === 'ValidUntil') && value) {
+      const parsed = parse(value, 'dd/MM/yyyy H.mm.ss', new Date());
+      if (!isValid(parsed)) {
+        return value;
+      }
+      return format(parsed, 'd.M.yyyy');
+    }
+    return value;
+  };
+
+  // Returns the correct edited field label
+  const getEditedFieldLabel = (field: string) => {
+    switch (field) {
+      case 'CreatedPositionName':
+        return t('change_logs.fields.CreatedPositionName');
+      case 'Name':
+        return t('admin_panel.position_name.name');
+      case 'ValidFrom':
+        return t('admin_panel.position_name.valid_from');
+      case 'ValidUntil':
+        return t('admin_panel.position_name.valid_until');
+      default:
+        return field;
+    }
+  };
+
+  const { data: positionNameChangelogs = [] /* isLoading: positionNameChangeLogsLoading */ } =
+    useGetAdminChangelogsByObjectIdQuery(positionName.id);
+
+  return (
+    <Box
+      sx={{
+        backgroundColor: '#FFFFFF',
+        borderRadius: '0px',
+        boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)',
+      }}
+    >
+      {
+        <Box sx={{ padding: 2, display: 'flex', justifyContent: 'right' }}>
+          <RequiresEditRole>
+            <Button
+              variant="contained"
+              onClick={handleOpenEditModal}
+              sx={{
+                backgroundColor: '#223B7C',
+                color: 'white',
+                fontSize: '1rem',
+                padding: '10px 20px',
+                height: '36px',
+                display: 'inline-flex',
+                borderRadius: '25px 8px 8px 25px',
+                fontWeight: 'bold',
+                textTransform: 'none',
+              }}
+              startIcon={
+                <Box
+                  component="span"
+                  sx={{
+                    marginRight: '8px',
+                  }}
+                >
+                  ✎
+                </Box>
+              }
+            >
+              {t('admin_panel.position_name.edit')}
+            </Button>
+          </RequiresEditRole>
+        </Box>
+      }
+      <Box
+        sx={{
+          display: 'flex',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          p: 2,
+          height: 40,
+          borderBottom: '1px solid #ccc',
+          bgcolor: '#223B7C',
+          color: 'white',
+          borderTopLeftRadius: 1,
+          borderTopRightRadius: 1,
+        }}
+      >
+        <Typography
+          sx={{
+            color: 'white',
+            fontSize: '1.2rem',
+            textTransform: 'none',
+          }}
+        >
+          {t('admin_panel.position_name.details')}
+        </Typography>
+      </Box>
+
+      <Box padding={2}>
+        <Grid2 container spacing={2} size={16}>
path: frontend/src/components/Details/PositionNameDetails.tsx
user: login: hwps
body: Check.
 
Noi containerit menee, jos ymmärsin oikein, niin että niiden koko on kovakoodattu olemaan aina 12, eli size ei tee niissä mitään. Lapsille annetaan lukema 1-12 joka kertoo paljonko vievät palstatilaa.
created_at: 2025-04-22T17:32:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,370 @@
+import React, { useState, useEffect } from 'react';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableContainer,
+  TableHead,
+  TableRow,
+  Box,
+  CircularProgress,
+  alpha,
+  TablePagination,
+  TextField,
+  Button,
+  Grid2,
+  Checkbox,
+  FormControlLabel,
+} from '@mui/material';
+
+import { useTranslation } from 'react-i18next';
+
+import type { PositionName } from 'models/PositionName';
+
+import { useGetPositionNamesQuery } from 'redux/api-slices/functions/position-names-api';
+import PositionNameDetails from 'components/Details/PositionNameDetails';
+import { RequiresEditRole } from 'components/role-guards';
+import EditPositionNameModal from 'components/Modal/EditPositionNameModal';
+import { format } from 'date-fns';
+
+const PositionNameAdmin = () => {
+  const { t } = useTranslation();
+
+  const [expandedRow, setExpandedRow] = useState<PositionName | null>(null);
+  const [filteredPositionNames, setFilteredPositionNames] = useState<PositionName[]>([]);
+  const [sortConfig, setSortConfig] = useState<{ key: keyof PositionName; direction: 'asc' | 'desc' } | null>(null);
+  const [page, setPage] = useState<number>(0);
+  const [rowsPerPage, setRowsPerPage] = useState<number>(10);
+  const [searchPositionName, setSearchPositionName] = useState<string>('');
+  const [showOnlyActive, setShowOnlyActive] = useState<boolean>(false);
+  const [createModalOpen, setCreateModalOpen] = useState<boolean>(false);
+
+  const { data: positionNames = [], isLoading: positionNamesLoading } = useGetPositionNamesQuery();
+
+  const isLoading = positionNamesLoading;
+
+  useEffect(() => {
+    if (positionNames.length > 0) {
+      setFilteredPositionNames(applyDefaultSorting(positionNames));
+    } else {
+      setFilteredPositionNames([]);
+    }
+  }, [positionNames]);
+
+  const applyDefaultSorting = (data: PositionName[]) => {
+    return [...data].sort((a, b) => {
+      return a.name.localeCompare(b.name, 'fi');
+    });
+  };
+
+  // UI callbacks
+
+  const handleOpenCreateModal = () => {
+    setCreateModalOpen(true);
+  };
+
+  const handleCloseCreateModal = () => {
+    setCreateModalOpen(false);
+  };
+
+  const handleRowToggle = (subject: PositionName) => {
+    setExpandedRow(expandedRow && expandedRow.id === subject.id ? null : subject);
+  };
+
+  const handleCloseDetails = () => {
+    setExpandedRow(null);
+  };
+
+  const handleChangePage = (_: unknown, newPage: number) => {
+    setPage(newPage);
+  };
+
+  const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setRowsPerPage(parseInt(event.target.value, 10));
+    setPage(0);
+  };
+
+  /* Sorting logic */
+  const handleSort = (key: keyof PositionName) => {
+    setSortConfig((prevConfig) => {
+      setPage(0);
+
+      if (!prevConfig || prevConfig.key !== key) {
+        // New key, sort the data based on the selected key and direction
+        const sortedData = [...filteredPositionNames].sort((a, b) => {
+          const valA = a[key] ?? '';
+          const valB = b[key] ?? '';
+
+          // Sort by date
+          if (key === 'validFrom' || key === 'validUntil') {
+            return (new Date(valA).getTime() || 0) - (new Date(valB).getTime() || 0);
+          }
+
+          // Sort by name
+          return valA.localeCompare(valB, 'fi');
+          //return valA < valB ? -1 : valA > valB ? 1 : 0;
+        });
+
+        setFilteredPositionNames(sortedData);
+        return { key, direction: 'asc' };
+      } else if (prevConfig.direction === 'asc') {
+        // Previous config is the same key and ascending, so sort in descending order
+        const sortedData = [...filteredPositionNames].sort((a, b) => {
+          const valA = a[key] ?? '';
+          const valB = b[key] ?? '';
+
+          // Sort by date
+          if (key === 'validFrom' || key === 'validUntil') {
+            return (new Date(valB).getTime() || 0) - (new Date(valA).getTime() || 0);
+          }
+
+          // Sort by name
+          return valB.localeCompare(valA, 'fi');
+          //return valA > valB ? -1 : valA < valB ? 1 : 0;
+        });
+
+        setFilteredPositionNames(sortedData);
+        return { key, direction: 'desc' };
+      } else {
+        // Default sorting
+        setFilteredPositionNames(applyDefaultSorting(filteredPositionNames));
+        return null;
+      }
+    });
+  };
+
+  // Search logic
+  const handleSearch = () => {
+    const filtered = positionNames.filter((p) => {
+      const matchesName = searchPositionName
+        ? p['name'].toLowerCase().includes(searchPositionName.toLowerCase())
+        : true;
+
+      const matchesActive =
+        !showOnlyActive ||
+        ((p.validFrom
+          ? new Date(new Date(p.validFrom).setHours(0, 0, 0, 0)) <= new Date(new Date().setHours(0, 0, 0, 0))
+          : true) &&
+          (p.validUntil
+            ? new Date(new Date().setHours(0, 0, 0, 0)) <= new Date(new Date(p.validUntil).setHours(0, 0, 0, 0))
+            : true));
+
+      return matchesName && matchesActive;
+    });
+
+    setFilteredPositionNames(applyDefaultSorting(filtered));
+    setPage(0);
+  };
+
+  const handleSearchReset = () => {
+    setSearchPositionName('');
+    setFilteredPositionNames(applyDefaultSorting(positionNames));
+    setShowOnlyActive(false);
+    setPage(0);
+  };
+
+  // Pagination logic
+  const paginatedPositionNames = filteredPositionNames.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
+
+  if (isLoading) {
+    return (
+      <Box display="flex" justifyContent="center" alignItems="center" height="100%">
+        <CircularProgress />
+      </Box>
+    );
+  }
+
+  return (
+    <Box>
+      <Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2} mb={2}>
+        {/* Search Controls */}
+        <Box display="flex" flexWrap="wrap" gap={2}>
+          <Box display="flex" gap={2}>
+            {/* Search by name */}
+            <TextField
+              label={t('admin_panel.search.byName')}
+              value={searchPositionName}
+              onChange={(e) => setSearchPositionName(e.target.value)}
+              sx={{ minWidth: '300px' }}
+            />
+
+            {/* Search only active */}
+            <FormControlLabel
+              control={
+                <Checkbox
+                  checked={showOnlyActive}
+                  onChange={(event) => setShowOnlyActive(event.target.checked)}
+                  color="primary"
+                />
+              }
+              label={t('admin_panel.position_name.show_only_active')}
+              sx={{ minWidth: '250px' }}
+            />
+
+            {/* Search and clear buttons */}
+            <Box display="flex" gap={2} flexWrap="wrap">
+              <Button variant="contained" onClick={handleSearch}>
+                {t('search_filter.search')}
+              </Button>
+
+              <Button variant="outlined" onClick={handleSearchReset}>
+                {t('search_filter.reset')}
+              </Button>
+            </Box>
+          </Box>
+        </Box>
+
+        <RequiresEditRole>
+          {/* Create new cost centre button */}
+          <Button
+            variant="contained"
+            onClick={handleOpenCreateModal}
+            sx={{
+              backgroundColor: '#223B7C',
+              color: 'white',
+              fontSize: '1.0rem',
+              padding: '20px',
+              height: '40px',
+              display: 'flex',
+              borderRadius: '25px 8px 8px 25px',
+              fontWeight: 'bold',
+              textTransform: 'none',
+            }}
+            startIcon={
+              <Box
+                component="span"
+                sx={{
+                  marginRight: '10px',
+                }}
+              >
+                +
+              </Box>
+            }
+          >
+            {t('admin_panel.position_name.create_new')}
+          </Button>
+        </RequiresEditRole>
+      </Box>
+
+      <TableContainer>
+        <Table sx={{ minWidth: 650 }}>
+          {/* Table Header */}
+          <TableHead sx={{ backgroundColor: '#223B7C', height: '30px' }}>
+            <TableRow>
+              {/* Name header */}
+              <TableCell
+                sx={{
path: frontend/src/components/AdminPanel/PositionNameAdmin.tsx
user: login: hwps
body: Check.
created_at: 2025-04-22T17:33:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,272 @@
+import React from 'react';
+import { Box, TextField, Button, Grid2, IconButton, Typography } from '@mui/material';
+
+import RenderReadonlyTextField from 'components/Details/RenderReadonlyTextField';
+
+import Modal from '@mui/material/Modal';
+import CloseIcon from '@mui/icons-material/Close';
+import { Form, Field } from 'react-final-form';
+import { useTranslation } from 'react-i18next';
+
+import type { PositionName } from 'models/PositionName';
+import { useUpdatePositionNameMutation } from 'redux/api-slices/functions/position-names-api';
+import { useCreatePositionNameMutation } from 'redux/api-slices/functions/position-names-api';
+
+interface EditPositionNameModalProps {
+  open: boolean;
+  handleClose: () => void;
+  submitCallback: () => void;
+  positionName: PositionName | undefined;
+  allPositionNames: PositionName[];
+}
+
+interface FormValues {
+  name: string;
+  validFrom?: string;
+  validUntil?: string;
+}
+
+const EditPositionNameModal: React.FC<EditPositionNameModalProps> = ({
+  open,
+  handleClose,
+  submitCallback,
+  positionName,
+  allPositionNames,
+}) => {
+  const isCreateDialog: boolean = positionName === undefined ? true : false;
+
+  const { t } = useTranslation();
+
+  const [updateSubject] = useUpdatePositionNameMutation();
+  const [createSubject] = useCreatePositionNameMutation();
+
+  // Formats the date for the input field
+  const formatDateForInput = (dateString?: string): string => {
+    if (!dateString) return '';
+
+    const date = new Date(dateString);
+    if (isNaN(date.getTime())) return '';
+
+    const year = date.getFullYear();
+    const month = String(date.getMonth() + 1).padStart(2, '0');
+    const day = String(date.getDate()).padStart(2, '0');
+
+    return `${year}-${month}-${day}`;
+  };
+
+  const initialValues: Partial<PositionName> =
+    positionName !== undefined
+      ? {
+          name: positionName.name,
+          validFrom: formatDateForInput(positionName.validFrom),
+          validUntil: formatDateForInput(positionName.validUntil),
+        }
+      : {
+          name: '',
+          validFrom: '',
+          validUntil: '',
+        };
+
+  const validatePositionName = (value: string /* allValues: Record<string, any> */) => {
+    if (
+      value &&
+      allPositionNames.some((posName) => {
+        return posName.name === value.toLowerCase() && (isCreateDialog || (positionName && value != positionName.name));
+      })
+    )
+      return t('admin_panel.position_name.validation.duplicate_name');
+
+    return undefined;
+  };
+
+  // Checks whether the valid from date is before the valid until date
+  const validateValidUntil = (value: string, allValues: FormValues) => {
+    if (allValues.validFrom && value) {
+      const validFromDate = new Date(allValues.validFrom);
+      const validUntilDate = new Date(value);
+      if (validUntilDate <= validFromDate) {
+        return t('admin_panel.position_name.validation.invalid_date');
+      }
+    }
+    return undefined;
+  };
+
+  const onSubmit = async (values: FormValues) => {
+    console.log(values);
+    if (!positionName || !positionName.id) {
+      // create new position name
+      try {
+        const subjectData: Partial<PositionName> = {
+          name: values.name.toLowerCase(),
+          validFrom: values.validFrom ? new Date(values.validFrom).toISOString().split('T')[0] : undefined,
+          validUntil: values.validUntil ? new Date(values.validUntil).toISOString().split('T')[0] : undefined,
+        };
+        console.log('Creating new position name!');
+        createSubject(subjectData);
+        submitCallback();
+        //handleClose();
+        return;
+      } catch (error) {
+        console.error('Failed to create position name:', error);
+      }
+    } else {
+      // edit existing position name
+      try {
+        const updateData = {
+          name: values.name.toLowerCase(),
+          validFrom: values.validFrom ? new Date(values.validFrom).toISOString().split('T')[0] : undefined,
+          validUntil: values.validUntil ? new Date(values.validUntil).toISOString().split('T')[0] : undefined,
+        };
+        console.log(updateData);
+        updateSubject({ id: positionName.id, data: updateData });
+        submitCallback();
+        //handleClose();
+      } catch (error) {
+        console.error('Failed to update position name:', error);
+      }
+    }
+  };
+
+  return (
+    <Modal open={open} onClose={handleClose}>
+      <Box
+        sx={{
+          position: 'absolute',
+          top: '50%',
+          left: '50%',
+          transform: 'translate(-50%, -50%)',
+          width: '100%',
+          maxWidth: '800px',
+          maxHeight: '90vh',
+          overflow: 'auto',
+          bgcolor: 'background.paper',
+          boxShadow: 24,
+          p: 0,
+          borderRadius: 1,
+        }}
+      >
+        {/* Header */}
+        <Box
+          sx={{
+            display: 'flex',
+            justifyContent: 'space-between',
+            alignItems: 'center',
+            p: 2,
+            height: 50,
+            bgcolor: '#223B7C',
+            color: 'white',
+            borderTopLeftRadius: 1,
+            borderTopRightRadius: 1,
+          }}
+        >
+          <Typography variant="h6">
+            {isCreateDialog ? t('admin_panel.position_name.create_new') : t('admin_panel.position_name.edit')}
+          </Typography>
+          <IconButton onClick={handleClose} sx={{ color: 'white' }}>
+            <CloseIcon />
+          </IconButton>
+        </Box>
+
+        {/* Content */}
+        <Box sx={{ p: 4, pt: 0 }}>
+          <Form
+            onSubmit={onSubmit}
+            initialValues={initialValues}
+            render={({ handleSubmit, submitting, pristine, hasValidationErrors }) => (
+              <form onSubmit={handleSubmit}>
+                <Grid2 container spacing={2} size={12}>
+                  {/* Subtitle */}
+                  <Box sx={{ px: 2, mt: 2 }}>
+                    <Typography variant="h5" sx={{ fontWeight: 'bold', color: '#223b7c' }}>
+                      {t('admin_panel.position_name.details')}
+                    </Typography>
+                  </Box>
+                  {isCreateDialog || <RenderReadonlyTextField value={t('admin_panel.position_name.edit_warning')} />}
+                  {/* Name */}
+                  <Grid2 size={12}>
+                    <Field name="name" validate={validatePositionName}>
+                      {({ input, meta }) => {
+                        //console.log(input)
+                        return (
+                          <TextField
+                            {...input}
+                            fullWidth
path: frontend/src/components/Modal/EditPositionNameModal.tsx
user: login: hwps
body: Laitetaan EditSubjectModaliin sit kans. Check.
created_at: 2025-04-22T17:34:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-21T10:55:05Z
body: Frontend ESLint and/or Prettier issues were found and auto-fixed.

If there are remaining ESLint issues, they're unable to be auto-fixed, please `git pull` locally and address these manually.

To avoid this in the future, please set up ESLint and Prettier in your IDE for instant feedback and automatic runs on save.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-21T11:07:50Z
body: Frontend ESLint and/or Prettier issues were found and auto-fixed.

If there are remaining ESLint issues, they're unable to be auto-fixed, please `git pull` locally and address these manually.

To avoid this in the future, please set up ESLint and Prettier in your IDE for instant feedback and automatic runs on save.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-22T17:36:19Z
body: Frontend ESLint and/or Prettier issues were found and auto-fixed.

If there are remaining ESLint issues, they're unable to be auto-fixed, please `git pull` locally and address these manually.

To avoid this in the future, please set up ESLint and Prettier in your IDE for instant feedback and automatic runs on save.
author_association: CONTRIBUTOR