Layout changes

This commit is contained in:
araemer 2025-11-30 08:03:18 +01:00
parent e1eeef8d8a
commit bd6ee25910
4 changed files with 350 additions and 192 deletions

View file

@ -0,0 +1,41 @@
import {AlertCircle, X} from "lucide-react";
type ErrorPopupProps = {
message: string;
onClose: () => void;
};
/**
* ErrorPopup - Displays error messages in a modal overlay
*/
export default function ErrorPopup({message, onClose}: ErrorPopupProps) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/40 z-50">
<div className="bg-white rounded-lg p-6 w-96 shadow-lg">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<AlertCircle className="text-red-600" size={24}/>
<h3 className="text-lg font-semibold text-red-600">Fehler</h3>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={20}/>
</button>
</div>
<p className="text-gray-700 mb-4">{message}</p>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,160 @@
import {useState} from "react";
import type {UserDto} from "../../api/dtos/UserDto";
import Button from "../basics/Button";
import {ButtonType} from "../basics/BasicButtonDefinitions";
import PasswordField from "../basics/PasswordField";
import ButtonGroupLayout from "../basics/ButtonGroupLayout";
import TextLinkButton from "../basics/TextLinkButton";
import SelectField from "../basics/SelectField";
type UserEditFormProps = {
user: UserDto;
isAdmin: boolean;
isSaving: boolean;
onSave: (user: UserDto, password?: string) => Promise<void>;
onCancel: () => void;
onOpenPasswordModal: () => void;
};
/**
* UserEditForm - Form for creating/editing a user
*/
export default function UserEditForm({
user,
isAdmin,
isSaving,
onSave,
onCancel,
onOpenPasswordModal,
}: UserEditFormProps) {
const [editedUser, setEditedUser] = useState<UserDto>({...user});
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const handleSave = async () => {
if (!editedUser.id) {
// New user - validate passwords
if (confirmPassword !== password) {
setPasswordError("Passwörter stimmen nicht überein");
return;
}
if (!password || password.length === 0) {
setPasswordError("Passwort ist erforderlich");
return;
}
setPasswordError("");
await onSave(editedUser, password);
} else {
// Existing user
await onSave(editedUser);
}
};
// @todo API string
const roleOptions = [
{value: "user", label: "Benutzer"},
{value: "admin", label: "Administrator"},
];
return (
<div className="px-6 py-2">
<h2>{editedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"}</h2>
<div className="flex flex-col gap-3 max-w-md">
<label>Benutzername</label>
<input
type="text"
placeholder="Benutzername"
value={editedUser.userName}
onChange={(e) =>
setEditedUser({...editedUser, userName: e.target.value})
}
/>
<label>Vorname</label>
<input
type="text"
placeholder="Vorname"
value={editedUser.firstName ?? ""}
onChange={(e) =>
setEditedUser({...editedUser, firstName: e.target.value})
}
/>
<label>Nachname</label>
<input
type="text"
placeholder="Nachname"
value={editedUser.lastName ?? ""}
onChange={(e) =>
setEditedUser({...editedUser, lastName: e.target.value})
}
/>
<label>E-Mail</label>
<input
type="email"
placeholder="E-Mail"
value={editedUser.email ?? ""}
onChange={(e) =>
setEditedUser({...editedUser, email: e.target.value})
}
/>
{isAdmin && (
<>
<label>Benutzergruppe</label>
<SelectField
value={editedUser.role ?? "user"}
onChange={(value) =>
setEditedUser({...editedUser, role: value})
}
options={roleOptions}
/>
</>
)}
{/* Show password field only when creating new user */}
{!editedUser.id && (
<>
<label>Passwort</label>
<PasswordField
onPasswordChanged={setPassword}
onKeyDown={() => {
}}
/>
<label>Passwort bestätigen</label>
<PasswordField
placeholder="Passwort bestätigen"
onPasswordChanged={setConfirmPassword}
onKeyDown={() => {
}}
/>
{passwordError && <p className="error-text">{passwordError}</p>}
</>
)}
{/* Change password link for existing user */}
{editedUser.id && (
<div className="mt-2 text-right">
<TextLinkButton
text="Passwort ändern"
onClick={onOpenPasswordModal}
/>
</div>
)}
<ButtonGroupLayout>
<Button
text={isSaving ? "Speichern..." : "Speichern"}
onClick={handleSave}
buttonType={ButtonType.PrimaryButton}
disabled={isSaving}
/>
<Button text="Abbrechen" onClick={onCancel} disabled={isSaving}/>
</ButtonGroupLayout>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import clsx from "clsx";
import type {UserDto} from "../../api/dtos/UserDto";
type UserListProps = {
users: UserDto[];
selectedUser: UserDto | null;
onSelectUser: (user: UserDto) => void;
};
/**
* UserList - Displays a list of users with selection
*/
export default function UserList({users, selectedUser, onSelectUser}: UserListProps) {
const formatUserName = (user: UserDto) => {
const parts = [];
if (user.lastName) parts.push(user.lastName);
if (user.firstName) parts.push(user.firstName);
return parts.length > 0 ? parts.join(", ") : "";
};
return (
<div className="border-r border-gray-300 h-full">
<h2 className="px-4 py-2">Benutzer</h2>
<ul>
{users.map((user) => (
<li
key={user.id ?? user.userName}
className={clsx(
"px-4 py-3 cursor-pointer hover:bg-gray-200 transition-colors border-b border-gray-300",
selectedUser?.id === user.id && "bg-blue-100 hover:bg-blue-200"
)}
onClick={() => onSelectUser(user)}
>
<div className={clsx(
"text-base",
selectedUser?.id === user.id && "font-semibold"
)}>
{formatUserName(user)}
</div>
<div className="text-sm text-gray-600 mt-1">
{user.userName}
</div>
</li>
))}
</ul>
</div>
);
}

View file

@ -1,29 +1,28 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {createUser, fetchAllUsers, fetchCurrentUser, updateUser} from "../../api/points/UserPoint"; import {createUser, fetchAllUsers, fetchCurrentUser, updateUser,} from "../../api/points/UserPoint";
import type {UserDto} from "../../api/dtos/UserDto.ts"; import type {UserDto} from "../../api/dtos/UserDto";
import ContentBackground from "../basics/ContentBackground"; import ContentBackground from "../basics/ContentBackground";
import ContentBody from "../basics/ContentBody"; import ContentBody from "../basics/ContentBody";
import StickyHeader from "../basics/StickyHeader"; import StickyHeader from "../basics/StickyHeader";
import Button from "../basics/Button"; import Button from "../basics/Button";
import {ButtonType} from "../basics/BasicButtonDefinitions"; import {ButtonType} from "../basics/BasicButtonDefinitions";
import clsx from "clsx"; import PageContainer from "../basics/PageContainer";
import PageContainer from "../basics/PageContainer.tsx"; import {ChangePasswordModal} from "./ChangePasswordModal";
import {ChangePasswordModal} from "./ChangePasswordModal.tsx"; import ButtonGroupLayout from "../basics/ButtonGroupLayout";
import PasswordField from "../basics/PasswordField.tsx";
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
import {Plus, X} from "lucide-react"; import {Plus, X} from "lucide-react";
import ButtonLink from "../basics/ButtonLink.tsx"; import ButtonLink from "../basics/ButtonLink";
import {getRecipeListUrl} from "../../routes.ts"; import {getRecipeListUrl} from "../../routes";
import TextLinkButton from "../basics/TextLinkButton.tsx"; import type {CreateUserResponse} from "../../api/dtos/CreateUserResponse";
import SelectField from "../basics/SelectField.tsx"; import type {UserListResponse} from "../../api/dtos/UserListResponse";
import type {CreateUserResponse} from "../../api/dtos/CreateUserResponse.ts"; import UserList from "./UserList";
import type {UserListResponse} from "../../api/dtos/UserListResponse.ts"; import UserEditForm from "./UserEditForm";
import ErrorPopup from "../basics/ErrorPopup";
/** /**
* UserManagementPage * UserManagementPage
* ------------------- * -------------------
* Displays a two-column layout: * Displays a two-column layout:
* - Left: list of all users (wider on desktop) * - Left: list of all users
* - Right: edit form for selected or new user * - Right: edit form for selected or new user
* *
* Allows: * Allows:
@ -36,43 +35,49 @@ export default function UserManagementPage() {
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null); const [selectedUser, setSelectedUser] = useState<UserDto | null>(null);
const [currentUser, setCurrentUser] = useState<UserDto | null>(null); const [currentUser, setCurrentUser] = useState<UserDto | null>(null);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isAdmin = currentUser?.role === "admin"; //@todo API enum
const adminRole: string = "admin";
const isAdmin = currentUser?.role === adminRole;
// Load current user and user list (if admin) // Load current user and user list (if admin)
useEffect(() => { useEffect(() => {
loadData().catch(console.error); loadData();
}, []); }, []);
const loadData = async () => { const loadData = async () => {
const me = await fetchCurrentUser(); try {
setCurrentUser(me); const me = await fetchCurrentUser();
setCurrentUser(me);
if (me.role === "admin") { if (me.role === adminRole) {
const userResponse: UserListResponse = await fetchAllUsers(); const userResponse: UserListResponse = await fetchAllUsers();
// Sort users alphabetically by last name, then first name // Sort users alphabetically by last name, then first name
const sortedUsers = userResponse.valueList.sort((a, b) => { const sortedUsers = userResponse.valueList.sort((a, b) => {
// Compare last names first const lastNameCompare = (a.lastName || "").localeCompare(
const lastNameCompare = (a.lastName || "").localeCompare(b.lastName || ""); b.lastName || ""
if (lastNameCompare !== 0) { );
return lastNameCompare; if (lastNameCompare !== 0) {
} return lastNameCompare;
// If last names are equal, compare first names }
return (a.firstName || "").localeCompare(b.firstName || ""); return (a.firstName || "").localeCompare(b.firstName || "");
}); });
setUsers(sortedUsers); setUsers(sortedUsers);
setSelectedUser(me);
// Pre-select current user (admin's own profile) } else {
setSelectedUser(me); setUsers([me]);
} else { setSelectedUser(me);
setUsers([me]); }
setSelectedUser(me); } catch (err) {
setError(
err instanceof Error
? err.message
: "Fehler beim Laden der Benutzerdaten"
);
} }
}; };
@ -82,30 +87,42 @@ export default function UserManagementPage() {
}; };
/** Handles saving user (create or update) */ /** Handles saving user (create or update) */
const handleSave = async () => { const handleSave = async (user: UserDto, password?: string) => {
if (!selectedUser) return;
setIsSaving(true); setIsSaving(true);
setError(null);
try { try {
if (!selectedUser.id) { if (!user.id) {
// New user - check passwords and save // New user
if (confirmPassword !== password) { const response: CreateUserResponse = await createUser({
setPasswordError("Passwords do not match"); userData: user,
return; password: password || "",
} });
const response: CreateUserResponse = await createUser({userData: selectedUser, password});
const userDto = response.userData; const userDto = response.userData;
if (userDto) { if (userDto) {
// add user to list and slect const newUsers = [...users, userDto].sort((a, b) => {
setUsers([...users, userDto]); const lastNameCompare = (a.lastName || "").localeCompare(
b.lastName || ""
);
if (lastNameCompare !== 0) {
return lastNameCompare;
}
return (a.firstName || "").localeCompare(b.firstName || "");
});
setUsers(newUsers);
setSelectedUser(userDto); setSelectedUser(userDto);
} }
} else { } else {
// existing user - update // Existing user
const updated = await updateUser(selectedUser); const updated = await updateUser(user);
// update user data in user list and select correct user setUsers(users.map((u) => (u.id === updated.id ? updated : u)));
setUsers(users.map(u => (u.id === updated.id ? updated : u)));
setSelectedUser(updated); setSelectedUser(updated);
} }
} catch (err) {
setError(
err instanceof Error
? err.message
: "Fehler beim Speichern der Benutzerdaten"
);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@ -113,8 +130,6 @@ export default function UserManagementPage() {
/** Opens password modal */ /** Opens password modal */
const openPasswordModal = () => { const openPasswordModal = () => {
setPassword("");
setConfirmPassword("");
setIsPasswordModalOpen(true); setIsPasswordModalOpen(true);
}; };
@ -129,16 +144,10 @@ export default function UserManagementPage() {
}); });
}; };
// @todo API enum!
const roleOptions = [
{value: "user", label: "Benutzer"},
{value: "admin", label: "Administrator"}
];
return ( return (
<PageContainer> <PageContainer>
<ContentBackground> <ContentBackground>
<StickyHeader className={"flex justify-between items-center"}> <StickyHeader className="flex justify-between items-center">
<h1>Benutzerverwaltung</h1> <h1>Benutzerverwaltung</h1>
<ButtonGroupLayout> <ButtonGroupLayout>
{isAdmin && ( {isAdmin && (
@ -149,141 +158,36 @@ export default function UserManagementPage() {
buttonType={ButtonType.PrimaryButton} buttonType={ButtonType.PrimaryButton}
/> />
)} )}
<ButtonLink <ButtonLink icon={X} to={getRecipeListUrl()}/>
icon={X}
to={getRecipeListUrl()}
/>
</ButtonGroupLayout> </ButtonGroupLayout>
</StickyHeader> </StickyHeader>
<ContentBody> <ContentBody className="p-0">
<div className="flex flex-col md:flex-row gap-0"> <div className="flex flex-col md:flex-row min-h-[500px]">
{/* User List - Wider on desktop, attached to left */} {/* User List - Left side, no padding */}
<div className="md:w-1/2 lg:w-2/5 border-r border-gray-300 pr-6"> <div className="md:w-2/5 lg:w-1/3">
<h2>Benutzer</h2> <UserList
<ul className="space-y-1"> users={users}
{users.map((user) => ( selectedUser={selectedUser}
<li onSelectUser={handleSelectUser}
key={user.id ?? user.userName} />
className={clsx(
"p-3 rounded cursor-pointer hover:bg-gray-200 transition-colors",
selectedUser?.id === user.id && "bg-blue-100 hover:bg-blue-200 font-semibold"
)}
onClick={() => handleSelectUser(user)}
>
{user.lastName}, {user.firstName} ({user.userName})
</li>
))}
</ul>
</div> </div>
{/* Edit Form */} {/* Edit Form - Right side, takes remaining space */}
<div className="md:w-1/2 lg:w-3/5 pl-6"> <div className="md:w-3/5 lg:w-2/3">
{selectedUser ? ( {selectedUser ? (
<div> <UserEditForm
<h2> user={selectedUser}
{selectedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"} isAdmin={isAdmin}
</h2> isSaving={isSaving}
<div className="flex flex-col gap-3 max-w-md"> onSave={handleSave}
<label>Benutzername</label> onCancel={loadData}
<input onOpenPasswordModal={openPasswordModal}
type="text" />
placeholder="Benutzername"
value={selectedUser.userName}
onChange={(e) =>
setSelectedUser({...selectedUser, userName: e.target.value})
}
/>
<label>Vorname</label>
<input
type="text"
placeholder="Vorname"
value={selectedUser.firstName ?? ""}
onChange={(e) =>
setSelectedUser({...selectedUser, firstName: e.target.value})
}
/>
<label>Nachname</label>
<input
type="text"
placeholder="Nachname"
value={selectedUser.lastName ?? ""}
onChange={(e) =>
setSelectedUser({...selectedUser, lastName: e.target.value})
}
/>
<label>E-Mail</label>
<input
type="email"
placeholder="E-Mail"
value={selectedUser.email ?? ""}
onChange={(e) =>
setSelectedUser({...selectedUser, email: e.target.value})
}
/>
{isAdmin && (
<>
<label>Benutzergruppe</label>
<SelectField
value={selectedUser.role ?? "user"}
onChange={(value) =>
setSelectedUser({...selectedUser, role: value})
}
options={roleOptions}
/>
</>
)}
{/* Show password field only when creating new user */}
{!selectedUser.id && (
<>
<label>Passwort</label>
<PasswordField
onPasswordChanged={setPassword}
onKeyDown={() => {
}}
/>
<label>Passwort bestätigen</label>
<PasswordField
placeholder="Passwort bestätigen"
onPasswordChanged={setConfirmPassword}
onKeyDown={() => {
}}
/>
{passwordError && <p className="error-text mb-2">{passwordError}</p>}
</>
)}
{/* Change password link for existing user */}
{selectedUser.id && (
<div className="mt-2 text-right">
<TextLinkButton
text="Passwort ändern"
onClick={openPasswordModal}
/>
</div>
)}
<ButtonGroupLayout>
<Button
text={isSaving ? "Speichern..." : "Speichern"}
onClick={handleSave}
buttonType={ButtonType.PrimaryButton}
/>
<Button
text={"Abbrechen"}
onClick={loadData}
/>
</ButtonGroupLayout>
</div>
</div>
) : ( ) : (
<p className="text-gray-600">Bitte einen Benutzer auswählen.</p> <div className="px-6 py-4">
<p className="text-gray-600">Bitte einen Benutzer auswählen.</p>
</div>
)} )}
</div> </div>
</div> </div>
@ -296,6 +200,11 @@ export default function UserManagementPage() {
onClose={() => setIsPasswordModalOpen(false)} onClose={() => setIsPasswordModalOpen(false)}
/> />
)} )}
{/* Error Popup */}
{error && (
<ErrorPopup message={error} onClose={() => setError(null)}/>
)}
</ContentBackground> </ContentBackground>
</PageContainer> </PageContainer>
); );