From abb337c6835502d1c2765751525d45a334fd8291 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 24 Mar 2026 14:02:16 +0100 Subject: [PATCH] rework internal order system --- .../ausruestungsanfrage.controller.ts | 29 +++- .../052_anfrage_fuer_benutzer_name.sql | 2 + .../src/routes/ausruestungsanfrage.routes.ts | 6 + .../services/ausruestungsanfrage.service.ts | 32 ++++- frontend/src/components/chat/ChatMessage.tsx | 95 ++++++++----- .../src/components/chat/MessageReactions.tsx | 127 ++++++++++++++---- frontend/src/pages/Ausruestungsanfrage.tsx | 39 ++++-- frontend/src/services/ausruestungsanfrage.ts | 5 +- .../src/types/ausruestungsanfrage.types.ts | 1 + 9 files changed, 261 insertions(+), 75 deletions(-) create mode 100644 backend/src/database/migrations/052_anfrage_fuer_benutzer_name.sql diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index 12fa679..d9035aa 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -208,6 +208,20 @@ class AusruestungsanfrageController { } } + // ------------------------------------------------------------------------- + // Users (for "order on behalf of" autocomplete) + // ------------------------------------------------------------------------- + + async getAllUsers(_req: Request, res: Response): Promise { + try { + const users = await ausruestungsanfrageService.getAllUsers(); + res.status(200).json({ success: true, data: users }); + } catch (error) { + logger.error('AusruestungsanfrageController.getAllUsers error', { error }); + res.status(500).json({ success: false, message: 'Benutzer konnten nicht geladen werden' }); + } + } + // ------------------------------------------------------------------------- // Requests // ------------------------------------------------------------------------- @@ -251,11 +265,12 @@ class AusruestungsanfrageController { async createRequest(req: Request, res: Response): Promise { try { - const { items, notizen, bezeichnung, fuer_benutzer_id } = req.body as { + const { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name } = req.body as { items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; + fuer_benutzer_name?: string; }; if (!items || items.length === 0) { @@ -276,6 +291,7 @@ class AusruestungsanfrageController { // Determine anfrager: self or on behalf of another user let anfragerId = req.user!.id; + let storedFuerBenutzerName: string | undefined; if (fuer_benutzer_id && fuer_benutzer_id !== req.user!.id) { const groups = req.user?.groups ?? []; const canOrderForUser = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:order_for_user'); @@ -284,9 +300,18 @@ class AusruestungsanfrageController { return; } anfragerId = fuer_benutzer_id; + } else if (fuer_benutzer_name && !fuer_benutzer_id) { + // Custom name for user not in system — keep anfrager_id as current user + const groups = req.user?.groups ?? []; + const canOrderForUser = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:order_for_user'); + if (!canOrderForUser) { + res.status(403).json({ success: false, message: 'Keine Berechtigung für Bestellung im Auftrag' }); + return; + } + storedFuerBenutzerName = fuer_benutzer_name; } - const request = await ausruestungsanfrageService.createRequest(anfragerId, items, notizen, bezeichnung); + const request = await ausruestungsanfrageService.createRequest(anfragerId, items, notizen, bezeichnung, storedFuerBenutzerName); res.status(201).json({ success: true, data: request }); } catch (error) { logger.error('AusruestungsanfrageController.createRequest error', { error }); diff --git a/backend/src/database/migrations/052_anfrage_fuer_benutzer_name.sql b/backend/src/database/migrations/052_anfrage_fuer_benutzer_name.sql new file mode 100644 index 0000000..755bbd9 --- /dev/null +++ b/backend/src/database/migrations/052_anfrage_fuer_benutzer_name.sql @@ -0,0 +1,2 @@ +-- Add fuer_benutzer_name column for custom names (users not in system) +ALTER TABLE ausruestung_anfragen ADD COLUMN IF NOT EXISTS fuer_benutzer_name TEXT; diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index 6bacb6a..54b17a6 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -32,6 +32,12 @@ router.delete('/eigenschaften/:eigenschaftId', authenticate, requirePermission(' // Legacy text-based categories router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController)); +// --------------------------------------------------------------------------- +// Users (for "order on behalf of" autocomplete) +// --------------------------------------------------------------------------- + +router.get('/users', authenticate, requirePermission('ausruestungsanfrage:order_for_user'), ausruestungsanfrageController.getAllUsers.bind(ausruestungsanfrageController)); + // --------------------------------------------------------------------------- // Overview // --------------------------------------------------------------------------- diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 2249d89..f41e8ec 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -266,6 +266,20 @@ async function deleteArtikelEigenschaft(id: number) { await pool.query('DELETE FROM ausruestung_artikel_eigenschaften WHERE id = $1', [id]); } +// --------------------------------------------------------------------------- +// Users (for "order on behalf of" autocomplete) +// --------------------------------------------------------------------------- + +async function getAllUsers() { + const result = await pool.query( + `SELECT id, COALESCE(given_name || ' ' || family_name, name) AS name + FROM users + WHERE is_active = true + ORDER BY name`, + ); + return result.rows; +} + // --------------------------------------------------------------------------- // Requests (ausruestung_anfragen) // --------------------------------------------------------------------------- @@ -288,6 +302,7 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string }) `SELECT a.*, COALESCE(u.given_name || ' ' || u.family_name, u.name) AS anfrager_name, COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name, + a.fuer_benutzer_name, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count FROM ausruestung_anfragen a @@ -316,7 +331,8 @@ async function getRequestById(id: number) { const reqResult = await pool.query( `SELECT a.*, COALESCE(u.given_name || ' ' || u.family_name, u.name) AS anfrager_name, - COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name + COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name, + a.fuer_benutzer_name FROM ausruestung_anfragen a LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u2 ON u2.id = a.bearbeitet_von @@ -388,6 +404,7 @@ async function createRequest( items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[], notizen?: string, bezeichnung?: string, + fuerBenutzerName?: string, ) { const client = await pool.connect(); try { @@ -407,20 +424,20 @@ async function createRequest( ); const nextNr = maxResult.rows[0].next_nr; const anfrageResult = await client.query( - `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, bestell_nummer, bestell_jahr) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, bestell_nummer, bestell_jahr, fuer_benutzer_name) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, - [userId, notizen || null, bezeichnung || null, nextNr, currentYear], + [userId, notizen || null, bezeichnung || null, nextNr, currentYear, fuerBenutzerName || null], ); await client.query('RELEASE SAVEPOINT sp_bestell_nr'); anfrage = anfrageResult.rows[0]; } catch { await client.query('ROLLBACK TO SAVEPOINT sp_bestell_nr'); const anfrageResult = await client.query( - `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung) - VALUES ($1, $2, $3) + `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, fuer_benutzer_name) + VALUES ($1, $2, $3, $4) RETURNING *`, - [userId, notizen || null, bezeichnung || null], + [userId, notizen || null, bezeichnung || null, fuerBenutzerName || null], ); anfrage = anfrageResult.rows[0]; } @@ -752,6 +769,7 @@ async function getWidgetOverview() { } export default { + getAllUsers, getKategorien, createKategorie, updateKategorie, diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index 833de4c..1d78093 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -10,7 +10,7 @@ import type { NextcloudMessage } from '../../types/nextcloud.types'; import FileMessageContent from './FileMessageContent'; import RichMessageText from './RichMessageText'; import PollMessageContent from './PollMessageContent'; -import MessageReactions from './MessageReactions'; +import { ReactionChips, AddReactionButton } from './MessageReactions'; const SENDER_COLORS = [ '#E53935', '#D81B60', '#8E24AA', '#5E35B1', @@ -44,9 +44,24 @@ function hasPollParam(params?: Record): boolean { const ChatMessage: React.FC = ({ message, isOwnMessage, isOneToOne, onReplyClick, onReactionToggled, reactionsOverride }) => { const [hovered, setHovered] = useState(false); + const hoverDelayTimer = useRef | null>(null); const longPressTimer = useRef | null>(null); const hideTimer = useRef | null>(null); + const handleMouseEnter = () => { + hoverDelayTimer.current = setTimeout(() => { + setHovered(true); + }, 200); + }; + + const handleMouseLeave = () => { + if (hoverDelayTimer.current) { + clearTimeout(hoverDelayTimer.current); + hoverDelayTimer.current = null; + } + setHovered(false); + }; + const handleTouchStart = () => { longPressTimer.current = setTimeout(() => { setHovered(true); @@ -73,6 +88,7 @@ const ChatMessage: React.FC = ({ message, isOwnMessage, isOneT } setHovered(false); }; + const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', @@ -96,7 +112,43 @@ const ChatMessage: React.FC = ({ message, isOwnMessage, isOneT const reactions = reactionsOverride?.reactions ?? message.reactions ?? {}; const reactionsSelf = reactionsOverride?.reactionsSelf ?? message.reactionsSelf ?? []; - const hasReactions = Object.keys(reactions).length > 0; + + /* Vertical toolbar with reply + add-reaction */ + const toolbar = ( + + {onReplyClick && ( + + onReplyClick(message)} + sx={{ width: 24, height: 24 }} + > + + + + )} + {onReactionToggled && ( + onReactionToggled(message.id)} + /> + )} + + ); return ( = ({ message, isOwnMessage, isOneT justifyContent: isOwnMessage ? 'flex-end' : 'flex-start', my: 0.5, px: 1, - alignItems: 'flex-end', + alignItems: 'flex-start', gap: 0.5, }} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} > - {/* Reply button for own messages (shown on left) */} - {isOwnMessage && onReplyClick && ( - - onReplyClick(message)} - sx={{ opacity: hovered ? 0.7 : 0, visibility: hovered ? 'visible' : 'hidden', transition: 'opacity 0.15s, visibility 0.15s', mb: 0.5 }} - > - - - - )} + {/* Own messages: toolbar on LEFT */} + {isOwnMessage && toolbar} = ({ message, isOwnMessage, isOneT - {/* Reactions — always rendered to avoid layout shift */} - onReactionToggled?.(message.id)} - visible={hovered || hasReactions} /> - {/* Reply button for other messages (shown on right) */} - {!isOwnMessage && onReplyClick && ( - - onReplyClick(message)} - sx={{ opacity: hovered ? 0.7 : 0, visibility: hovered ? 'visible' : 'hidden', transition: 'opacity 0.15s, visibility 0.15s', mb: 0.5 }} - > - - - - )} + {/* Partner messages: toolbar on RIGHT */} + {!isOwnMessage && toolbar} ); }; diff --git a/frontend/src/components/chat/MessageReactions.tsx b/frontend/src/components/chat/MessageReactions.tsx index a6622b7..cc1c952 100644 --- a/frontend/src/components/chat/MessageReactions.tsx +++ b/frontend/src/components/chat/MessageReactions.tsx @@ -34,24 +34,16 @@ const EXTENDED_EMOJI_CATEGORIES: { label: string; emojis: string[] }[] = [ }, ]; -interface MessageReactionsProps { - token: string; - messageId: number; - reactions: Record; - reactionsSelf: string[]; - onReactionToggled: () => void; - visible?: boolean; -} +/* ------------------------------------------------------------------ */ +/* Shared toggle helper */ +/* ------------------------------------------------------------------ */ -const MessageReactions: React.FC = ({ - token, - messageId, - reactions, - reactionsSelf, - onReactionToggled, - visible = true, -}) => { - const [anchorEl, setAnchorEl] = useState(null); +function useReactionToggle( + token: string, + messageId: number, + reactionsSelf: string[], + onReactionToggled: () => void, +) { const [loading, setLoading] = useState(null); const handleToggle = async (emoji: string) => { @@ -70,8 +62,33 @@ const MessageReactions: React.FC = ({ } }; + return { loading, handleToggle }; +} + +/* ------------------------------------------------------------------ */ +/* ReactionChips — existing reactions rendered below the bubble */ +/* ------------------------------------------------------------------ */ + +interface ReactionChipsProps { + token: string; + messageId: number; + reactions: Record; + reactionsSelf: string[]; + onReactionToggled: () => void; +} + +const ReactionChips: React.FC = ({ + token, + messageId, + reactions, + reactionsSelf, + onReactionToggled, +}) => { + const { loading, handleToggle } = useReactionToggle(token, messageId, reactionsSelf, onReactionToggled); const hasReactions = Object.keys(reactions).length > 0; + if (!hasReactions) return null; + return ( = ({ gap: 0.25, mt: 0.25, alignItems: 'center', - minHeight: 24, - visibility: visible ? 'visible' : 'hidden', - opacity: visible ? 1 : 0, - transition: 'opacity 0.15s, visibility 0.15s', }}> - {hasReactions && Object.entries(reactions).map(([emoji, count]) => ( + {Object.entries(reactions).map(([emoji, count]) => ( = ({ }} /> ))} + + ); +}; + +/* ------------------------------------------------------------------ */ +/* AddReactionButton — icon button + emoji popover */ +/* ------------------------------------------------------------------ */ + +interface AddReactionButtonProps { + token: string; + messageId: number; + reactionsSelf: string[]; + onReactionToggled: () => void; +} + +const AddReactionButton: React.FC = ({ + token, + messageId, + reactionsSelf, + onReactionToggled, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const { handleToggle } = useReactionToggle(token, messageId, reactionsSelf, onReactionToggled); + + return ( + <> setAnchorEl(e.currentTarget)} - sx={{ width: 20, height: 20, opacity: 0.6, '&:hover': { opacity: 1 } }} + sx={{ width: 24, height: 24, opacity: 0.6, '&:hover': { opacity: 1 } }} > @@ -157,8 +196,50 @@ const MessageReactions: React.FC = ({ + + ); +}; + +/* ------------------------------------------------------------------ */ +/* Legacy default export — kept for backward compatibility */ +/* (combines chips + add button, same interface as before) */ +/* ------------------------------------------------------------------ */ + +interface MessageReactionsProps { + token: string; + messageId: number; + reactions: Record; + reactionsSelf: string[]; + onReactionToggled: () => void; + visible?: boolean; +} + +const MessageReactions: React.FC = (props) => { + const { visible = true, ...rest } = props; + const hasReactions = Object.keys(rest.reactions).length > 0; + + return ( + + + ); }; +export { ReactionChips, AddReactionButton }; export default MessageReactions; diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index 5d4106e..e14a206 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -529,10 +529,14 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can <> {/* Meta info */} - {anfrage!.anfrager_name && ( + {(anfrage!.anfrager_name || anfrage!.fuer_benutzer_name) && ( Anfrage für - {anfrage!.anfrager_name} + + {anfrage!.fuer_benutzer_name + ? `${anfrage!.fuer_benutzer_name} (erstellt von ${anfrage!.anfrager_name || 'Unbekannt'})` + : anfrage!.anfrager_name} + )} @@ -987,7 +991,7 @@ function MeineAnfragenTab() { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newBezeichnung, setNewBezeichnung] = useState(''); const [newNotizen, setNewNotizen] = useState(''); - const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null); + const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null); const [newItems, setNewItems] = useState([{ bezeichnung: '', menge: 1 }]); // Track loaded eigenschaften per item row (by artikel_id) const [itemEigenschaften, setItemEigenschaften] = useState>({}); @@ -1016,8 +1020,8 @@ function MeineAnfragenTab() { }); const createMut = useMutation({ - mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string }) => - ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id), + mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string }) => + ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Anfrage erstellt'); @@ -1085,7 +1089,8 @@ function MeineAnfragenTab() { items: allItems, notizen: newNotizen || undefined, bezeichnung: newBezeichnung || undefined, - fuer_benutzer_id: newFuerBenutzer?.id, + fuer_benutzer_id: typeof newFuerBenutzer === 'object' && newFuerBenutzer ? newFuerBenutzer.id : undefined, + fuer_benutzer_name: typeof newFuerBenutzer === 'string' ? newFuerBenutzer : undefined, }); }; @@ -1181,11 +1186,27 @@ function MeineAnfragenTab() { /> {canOrderForUser && ( o.name} + getOptionLabel={o => typeof o === 'string' ? o : o.name} value={newFuerBenutzer} onChange={(_, v) => setNewFuerBenutzer(v)} - renderInput={params => } + onInputChange={(_, value, reason) => { + if (reason === 'input') { + // If user types a custom value that doesn't match any option, store as string + const match = orderUsers.find(u => u.name === value); + if (!match && value) { + setNewFuerBenutzer(value); + } else if (!value) { + setNewFuerBenutzer(null); + } + } + }} + isOptionEqualToValue={(option, value) => { + if (typeof option === 'string' || typeof value === 'string') return option === value; + return option.id === value.id; + }} + renderInput={params => } /> )} setDetailId(r.id)}> {formatOrderId(r)} {r.bezeichnung || '-'} - {r.anfrager_name || r.anfrager_id} + {r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id} {r.positionen_count ?? r.items_count ?? '-'} diff --git a/frontend/src/services/ausruestungsanfrage.ts b/frontend/src/services/ausruestungsanfrage.ts index 89a54fb..8a5c000 100644 --- a/frontend/src/services/ausruestungsanfrage.ts +++ b/frontend/src/services/ausruestungsanfrage.ts @@ -94,8 +94,9 @@ export const ausruestungsanfrageApi = { notizen?: string, bezeichnung?: string, fuer_benutzer_id?: string, + fuer_benutzer_name?: string, ): Promise => { - const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id }); + const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name }); return r.data.data; }, updateRequest: async ( @@ -140,7 +141,7 @@ export const ausruestungsanfrageApi = { // ── Users ── getOrderUsers: async (): Promise> => { - const r = await api.get('/api/permissions/users-with', { params: { permission: 'ausruestungsanfrage:create_request' } }); + const r = await api.get('/api/ausruestungsanfragen/users'); return r.data.data; }, }; diff --git a/frontend/src/types/ausruestungsanfrage.types.ts b/frontend/src/types/ausruestungsanfrage.types.ts index 65099c8..bef0eb8 100644 --- a/frontend/src/types/ausruestungsanfrage.types.ts +++ b/frontend/src/types/ausruestungsanfrage.types.ts @@ -79,6 +79,7 @@ export interface AusruestungAnfrage { id: number; anfrager_id: string; anfrager_name?: string; + fuer_benutzer_name?: string; bezeichnung?: string; status: AusruestungAnfrageStatus; notizen?: string;