From 9a52e413729c290af36f00c9ef77f4411086fdee Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 24 Mar 2026 13:28:46 +0100 Subject: [PATCH] rework internal order system --- .../ausruestungsanfrage.controller.ts | 27 ++++++ .../migrations/051_add_position_geliefert.sql | 3 + .../src/routes/ausruestungsanfrage.routes.ts | 6 ++ .../services/ausruestungsanfrage.service.ts | 45 +++++++++- frontend/src/components/chat/ChatMessage.tsx | 23 +++-- .../src/components/chat/MessageReactions.tsx | 87 ++++++++++++++++--- frontend/src/pages/Ausruestungsanfrage.tsx | 34 ++++++-- frontend/src/services/ausruestungsanfrage.ts | 5 ++ .../src/types/ausruestungsanfrage.types.ts | 1 + 9 files changed, 198 insertions(+), 33 deletions(-) create mode 100644 backend/src/database/migrations/051_add_position_geliefert.sql diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index 3702bda..12fa679 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -407,6 +407,33 @@ class AusruestungsanfrageController { } } + // ------------------------------------------------------------------------- + // Position delivery tracking + // ------------------------------------------------------------------------- + + async updatePositionGeliefert(req: Request, res: Response): Promise { + try { + const positionId = Number(req.params.positionId); + const { geliefert } = req.body as { geliefert?: boolean }; + + if (typeof geliefert !== 'boolean') { + res.status(400).json({ success: false, message: 'geliefert (boolean) ist erforderlich' }); + return; + } + + const position = await ausruestungsanfrageService.updatePositionGeliefert(positionId, geliefert); + if (!position) { + res.status(404).json({ success: false, message: 'Position nicht gefunden' }); + return; + } + + res.status(200).json({ success: true, data: position }); + } catch (error) { + logger.error('AusruestungsanfrageController.updatePositionGeliefert error', { error }); + res.status(500).json({ success: false, message: 'Lieferstatus konnte nicht aktualisiert werden' }); + } + } + // ------------------------------------------------------------------------- // Overview // ------------------------------------------------------------------------- diff --git a/backend/src/database/migrations/051_add_position_geliefert.sql b/backend/src/database/migrations/051_add_position_geliefert.sql new file mode 100644 index 0000000..7e9bf81 --- /dev/null +++ b/backend/src/database/migrations/051_add_position_geliefert.sql @@ -0,0 +1,3 @@ +-- Add per-item delivery tracking to request positions +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS geliefert BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index 2f6b07f..6bacb6a 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -51,6 +51,12 @@ router.patch('/requests/:id', authenticate, ausruestungsanfrageController.update router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController)); router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController)); +// --------------------------------------------------------------------------- +// Position delivery tracking +// --------------------------------------------------------------------------- + +router.patch('/positionen/:positionId/geliefert', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionGeliefert.bind(ausruestungsanfrageController)); + // --------------------------------------------------------------------------- // Linking requests to orders // --------------------------------------------------------------------------- diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index e633edc..fc08806 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -575,6 +575,33 @@ async function updateRequest( } } +async function updatePositionGeliefert(positionId: number, geliefert: boolean) { + const result = await pool.query( + `UPDATE ausruestung_anfrage_positionen SET geliefert = $1 WHERE id = $2 RETURNING *`, + [geliefert, positionId], + ); + const position = result.rows[0]; + if (!position) return null; + + // Auto-complete: if all positions are geliefert, set anfrage status to 'erledigt' + if (geliefert) { + const check = await pool.query( + `SELECT COUNT(*) FILTER (WHERE NOT geliefert)::int AS remaining + FROM ausruestung_anfrage_positionen + WHERE anfrage_id = $1`, + [position.anfrage_id], + ); + if (check.rows[0].remaining === 0) { + await pool.query( + `UPDATE ausruestung_anfragen SET status = 'erledigt', aktualisiert_am = NOW() WHERE id = $1`, + [position.anfrage_id], + ); + } + } + + return position; +} + async function updateRequestStatus( id: number, status: string, @@ -582,6 +609,7 @@ async function updateRequestStatus( bearbeitetVon?: string, ) { // Use aktualisiert_am (always exists) + try bearbeitet_am (added in migration 050) + let updated; try { const result = await pool.query( `UPDATE ausruestung_anfragen @@ -594,7 +622,7 @@ async function updateRequestStatus( RETURNING *`, [status, adminNotizen || null, bearbeitetVon || null, id], ); - return result.rows[0] || null; + updated = result.rows[0] || null; } catch { // Fallback if bearbeitet_am column doesn't exist yet (migration 050 not run) const result = await pool.query( @@ -607,8 +635,20 @@ async function updateRequestStatus( RETURNING *`, [status, adminNotizen || null, bearbeitetVon || null, id], ); - return result.rows[0] || null; + updated = result.rows[0] || null; } + + // When status changes to 'erledigt', mark all positions as geliefert + if (status === 'erledigt') { + try { + await pool.query( + `UPDATE ausruestung_anfrage_positionen SET geliefert = true WHERE anfrage_id = $1`, + [id], + ); + } catch { /* column may not exist yet */ } + } + + return updated; } async function deleteRequest(id: number) { @@ -716,6 +756,7 @@ export default { getRequests, getMyRequests, getRequestById, + updatePositionGeliefert, createRequest, updateRequest, updateRequestStatus, diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index f157c70..833de4c 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -120,7 +120,7 @@ const ChatMessage: React.FC = ({ message, isOwnMessage, isOneT onReplyClick(message)} - sx={{ opacity: hovered ? 0.7 : 0, transition: 'opacity 0.15s', mb: 0.5 }} + sx={{ opacity: hovered ? 0.7 : 0, visibility: hovered ? 'visible' : 'hidden', transition: 'opacity 0.15s, visibility 0.15s', mb: 0.5 }} > @@ -212,16 +212,15 @@ const ChatMessage: React.FC = ({ message, isOwnMessage, isOneT - {/* Reactions — shown on hover or when reactions exist */} - {(hovered || hasReactions) && ( - onReactionToggled?.(message.id)} - /> - )} + {/* Reactions — always rendered to avoid layout shift */} + onReactionToggled?.(message.id)} + visible={hovered || hasReactions} + /> {/* Reply button for other messages (shown on right) */} @@ -230,7 +229,7 @@ const ChatMessage: React.FC = ({ message, isOwnMessage, isOneT onReplyClick(message)} - sx={{ opacity: hovered ? 0.7 : 0, transition: 'opacity 0.15s', mb: 0.5 }} + sx={{ opacity: hovered ? 0.7 : 0, visibility: hovered ? 'visible' : 'hidden', transition: 'opacity 0.15s, visibility 0.15s', mb: 0.5 }} > diff --git a/frontend/src/components/chat/MessageReactions.tsx b/frontend/src/components/chat/MessageReactions.tsx index 973f86b..a6622b7 100644 --- a/frontend/src/components/chat/MessageReactions.tsx +++ b/frontend/src/components/chat/MessageReactions.tsx @@ -4,17 +4,43 @@ import Tooltip from '@mui/material/Tooltip'; import Chip from '@mui/material/Chip'; import IconButton from '@mui/material/IconButton'; import Popover from '@mui/material/Popover'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined'; import { nextcloudApi } from '../../services/nextcloud'; const QUICK_REACTIONS = ['\u{1F44D}', '\u2764\uFE0F', '\u{1F602}', '\u{1F62E}', '\u{1F622}', '\u{1F389}']; +const EXTENDED_EMOJI_CATEGORIES: { label: string; emojis: string[] }[] = [ + { + label: 'Smileys', + emojis: [ + '\u{1F600}', '\u{1F603}', '\u{1F604}', '\u{1F601}', '\u{1F605}', '\u{1F606}', '\u{1F923}', '\u{1F60A}', + '\u{1F607}', '\u{1F970}', '\u{1F60D}', '\u{1F618}', '\u{1F917}', '\u{1F914}', '\u{1F644}', '\u{1F612}', + ], + }, + { + label: 'Gesten', + emojis: [ + '\u{1F44D}', '\u{1F44E}', '\u{1F44F}', '\u{1F64C}', '\u{1F4AA}', '\u{1F91D}', '\u{1F64F}', '\u270C\uFE0F', + ], + }, + { + label: 'Objekte', + emojis: [ + '\u{1F525}', '\u2B50', '\u{1F4AF}', '\u2705', '\u274C', '\u26A0\uFE0F', '\u{1F680}', '\u{1F3AF}', + '\u{1F4A1}', '\u{1F389}', '\u{1F381}', '\u2764\uFE0F', '\u{1F494}', '\u{1F48E}', '\u{1F3C6}', '\u{1F4DD}', + ], + }, +]; + interface MessageReactionsProps { token: string; messageId: number; reactions: Record; reactionsSelf: string[]; onReactionToggled: () => void; + visible?: boolean; } const MessageReactions: React.FC = ({ @@ -23,6 +49,7 @@ const MessageReactions: React.FC = ({ reactions, reactionsSelf, onReactionToggled, + visible = true, }) => { const [anchorEl, setAnchorEl] = useState(null); const [loading, setLoading] = useState(null); @@ -46,7 +73,17 @@ const MessageReactions: React.FC = ({ const hasReactions = Object.keys(reactions).length > 0; return ( - + {hasReactions && Object.entries(reactions).map(([emoji, count]) => ( = ({ anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} > - - {QUICK_REACTIONS.map((emoji) => ( - { handleToggle(emoji); setAnchorEl(null); }} - sx={{ fontSize: '1rem', minWidth: 32, minHeight: 32 }} - > - {emoji} - - ))} + + {/* Quick reactions row */} + + {QUICK_REACTIONS.map((emoji) => ( + { handleToggle(emoji); setAnchorEl(null); }} + sx={{ fontSize: '1rem', minWidth: 32, minHeight: 32 }} + > + {emoji} + + ))} + + + {/* Extended emoji grid */} + + {EXTENDED_EMOJI_CATEGORIES.map((cat) => ( + + + {cat.label} + + + {cat.emojis.map((emoji) => ( + { handleToggle(emoji); setAnchorEl(null); }} + sx={{ fontSize: '1rem', minWidth: 28, minHeight: 28, p: 0.25 }} + > + {emoji} + + ))} + + + ))} + diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index 9aaa880..311c87f 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -49,7 +49,7 @@ interface EigenschaftFieldsProps { function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) { if (eigenschaften.length === 0) return null; return ( - + {eigenschaften.map(e => ( {e.typ === 'options' && e.optionen && e.optionen.length > 0 ? ( @@ -390,6 +390,15 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can onError: () => showError('Fehler beim Verknüpfen'), }); + const geliefertMut = useMutation({ + mutationFn: ({ positionId, geliefert }: { positionId: number; geliefert: boolean }) => + ausruestungsanfrageApi.updatePositionGeliefert(positionId, geliefert), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + const startEditing = () => { if (!detail) return; setEditBezeichnung(detail.anfrage.bezeichnung || ''); @@ -522,7 +531,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can {anfrage!.anfrager_name && ( - Anfrager + Anfrage für {anfrage!.anfrager_name} )} @@ -558,6 +567,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can + {showAdminActions && Geliefert} Artikel Menge Details @@ -565,9 +575,19 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can {detail.positionen.map(p => ( - + + {showAdminActions && ( + + geliefertMut.mutate({ positionId: p.id, geliefert: checked })} + /> + + )} - {p.bezeichnung} + {p.bezeichnung} {p.eigenschaften && p.eigenschaften.length > 0 && ( {p.eigenschaften.map(e => ( @@ -1118,7 +1138,7 @@ function MeineAnfragenTab() {
- Anfrage + Anfrage ID Bezeichnung Status Positionen @@ -1357,9 +1377,9 @@ function AlleAnfragenTab() {
- Anfrage + Anfrage ID Bezeichnung - Anfrager + Anfrage für Status Positionen Erstellt am diff --git a/frontend/src/services/ausruestungsanfrage.ts b/frontend/src/services/ausruestungsanfrage.ts index d47876c..89a54fb 100644 --- a/frontend/src/services/ausruestungsanfrage.ts +++ b/frontend/src/services/ausruestungsanfrage.ts @@ -113,6 +113,11 @@ export const ausruestungsanfrageApi = { await api.delete(`/api/ausruestungsanfragen/requests/${id}`); }, + // ── Position delivery tracking ── + updatePositionGeliefert: async (positionId: number, geliefert: boolean): Promise => { + await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/geliefert`, { geliefert }); + }, + // ── Linking ── linkToOrder: async (anfrageId: number, bestellungId: number): Promise => { await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId }); diff --git a/frontend/src/types/ausruestungsanfrage.types.ts b/frontend/src/types/ausruestungsanfrage.types.ts index 21a7b81..7c0f083 100644 --- a/frontend/src/types/ausruestungsanfrage.types.ts +++ b/frontend/src/types/ausruestungsanfrage.types.ts @@ -100,6 +100,7 @@ export interface AusruestungAnfragePosition { bezeichnung: string; menge: number; notizen?: string; + geliefert: boolean; erstellt_am: string; eigenschaften?: AusruestungPositionEigenschaft[]; }