diff --git a/backend/src/database/migrations/055_fix_wissen_bestellungen_permissions.sql b/backend/src/database/migrations/055_fix_wissen_bestellungen_permissions.sql new file mode 100644 index 0000000..f9ac491 --- /dev/null +++ b/backend/src/database/migrations/055_fix_wissen_bestellungen_permissions.sql @@ -0,0 +1,20 @@ +-- Migration 055: Ensure wissen:view and bestellungen:view are seeded for all dashboard groups +-- Re-seeds permissions that may be missing due to migration ordering or cascade deletes. +-- Uses ON CONFLICT DO NOTHING — safe to run multiple times. + +DO $$ +DECLARE + grp TEXT; +BEGIN + FOR grp IN + SELECT DISTINCT authentik_group FROM group_permissions WHERE authentik_group LIKE 'dashboard_%' + LOOP + INSERT INTO group_permissions (authentik_group, permission_id) + VALUES (grp, 'wissen:view'), (grp, 'wissen:widget_recent'), (grp, 'wissen:widget_search') + ON CONFLICT DO NOTHING; + + INSERT INTO group_permissions (authentik_group, permission_id) + VALUES (grp, 'bestellungen:view') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index 633fa50..8ec6495 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -117,7 +117,7 @@ async function getOrders(filters?: { status?: string; lieferant_id?: number; bes const result = await pool.query( `SELECT b.*, l.name AS lieferant_name, - u.display_name AS besteller_name, + COALESCE(u.name, u.preferred_username, u.email) AS besteller_name, COALESCE(pos.total_cost, 0) AS total_cost, COALESCE(pos.items_count, 0) AS items_count FROM bestellungen b @@ -145,7 +145,7 @@ async function getOrderById(id: number) { const orderResult = await pool.query( `SELECT b.*, l.name AS lieferant_name, - u.display_name AS besteller_name + COALESCE(u.name, u.preferred_username, u.email) AS besteller_name FROM bestellungen b LEFT JOIN lieferanten l ON l.id = b.lieferant_id LEFT JOIN users u ON u.id = b.erstellt_von @@ -158,7 +158,7 @@ async function getOrderById(id: number) { pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]), pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]), pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]), - pool.query(`SELECT h.*, u.display_name AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]), + pool.query(`SELECT h.*, COALESCE(u.name, u.preferred_username, u.email) AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]), ]); return { @@ -579,7 +579,7 @@ async function logAction(bestellungId: number, aktion: string, details: string, async function getHistory(bestellungId: number) { try { const result = await pool.query( - `SELECT h.*, u.display_name AS benutzer_name + `SELECT h.*, COALESCE(u.name, u.preferred_username, u.email) AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 diff --git a/backend/src/services/equipment.service.ts b/backend/src/services/equipment.service.ts index 268998f..8f04bfb 100644 --- a/backend/src/services/equipment.service.ts +++ b/backend/src/services/equipment.service.ts @@ -525,7 +525,7 @@ class EquipmentService { async getStatusHistory(equipmentId: string) { try { const result = await pool.query( - `SELECT h.*, u.display_name AS geaendert_von_name + `SELECT h.*, COALESCE(u.name, u.preferred_username, u.email) AS geaendert_von_name FROM ausruestung_status_historie h LEFT JOIN users u ON u.id = h.geaendert_von WHERE h.ausruestung_id = $1 diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index b1de760..9282959 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -653,7 +653,7 @@ class VehicleService { async getStatusHistory(fahrzeugId: string) { try { const result = await pool.query( - `SELECT h.*, u.display_name AS geaendert_von_name + `SELECT h.*, COALESCE(u.name, u.preferred_username, u.email) AS geaendert_von_name FROM fahrzeug_status_historie h LEFT JOIN users u ON u.id = h.geaendert_von WHERE h.fahrzeug_id = $1 diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 7e04fb7..797da60 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -4,6 +4,7 @@ import Paper from '@mui/material/Paper'; import IconButton from '@mui/material/IconButton'; import ChatIcon from '@mui/icons-material/Chat'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import AddIcon from '@mui/icons-material/Add'; import Typography from '@mui/material/Typography'; import Avatar from '@mui/material/Avatar'; import Badge from '@mui/material/Badge'; @@ -11,7 +12,7 @@ import Toolbar from '@mui/material/Toolbar'; import Tooltip from '@mui/material/Tooltip'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; -import { useLayout } from '../../contexts/LayoutContext'; +import { useLayout, CHAT_PANEL_MIN_WIDTH, CHAT_PANEL_MAX_WIDTH } from '../../contexts/LayoutContext'; import { ChatProvider, useChat } from '../../contexts/ChatContext'; import { Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -21,16 +22,18 @@ import { nextcloudApi } from '../../services/nextcloud'; import { safeOpenUrl } from '../../utils/safeOpenUrl'; import ChatRoomList from './ChatRoomList'; import ChatMessageView from './ChatMessageView'; +import NewChatDialog from './NewChatDialog'; const TRANSITION = 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)'; const COLLAPSED_WIDTH = 64; -const EXPANDED_WIDTH = 360; const ChatPanelInner: React.FC = () => { - const { chatPanelOpen, setChatPanelOpen } = useLayout(); + const { chatPanelOpen, setChatPanelOpen, chatPanelWidth, setChatPanelWidth } = useLayout(); const { rooms, selectedRoomToken, selectRoom, connected } = useChat(); const queryClient = useQueryClient(); const markedRoomsRef = React.useRef(new Set()); + const [newChatOpen, setNewChatOpen] = React.useState(false); + const isDraggingRef = React.useRef(false); const { data: externalLinks } = useQuery({ queryKey: ['external-links'], queryFn: () => configApi.getExternalLinks(), @@ -71,67 +74,120 @@ const ChatPanelInner: React.FC = () => { }).catch(() => {}); }, [selectedRoomToken, queryClient]); + const handleDragStart = React.useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'ew-resize'; + + const onMouseMove = (ev: MouseEvent) => { + if (!isDraggingRef.current) return; + const newWidth = window.innerWidth - ev.clientX; + const clamped = Math.min(Math.max(newWidth, CHAT_PANEL_MIN_WIDTH), CHAT_PANEL_MAX_WIDTH); + setChatPanelWidth(clamped); + }; + + const onMouseUp = () => { + isDraggingRef.current = false; + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, [setChatPanelWidth]); + if (!chatPanelOpen) { return ( - - - setChatPanelOpen(true)} aria-label="Chat öffnen"> - - - {connected && ( - - {rooms.map((room) => ( - - - { - setChatPanelOpen(true); - selectRoom(room.token); - }} - sx={{ p: 0.5 }} - > - + + + setChatPanelOpen(true)} aria-label="Chat öffnen"> + + + {connected && ( + + {rooms.map((room) => ( + + + { + setChatPanelOpen(true); + selectRoom(room.token); + }} + sx={{ p: 0.5 }} > - - {room.displayName.substring(0, 2).toUpperCase()} - - - - - - ))} - + + {room.displayName.substring(0, 2).toUpperCase()} + + + + + + ))} + + )} + {connected && ( + + { + setChatPanelOpen(true); + setNewChatOpen(true); + }} + sx={{ mb: 1 }} + aria-label="Neues Gespräch" + > + + + + )} + + {newChatOpen && ( + setNewChatOpen(false)} + onRoomCreated={(token) => { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + selectRoom(token); + setNewChatOpen(false); + }} + /> )} - + ); } @@ -139,7 +195,7 @@ const ChatPanelInner: React.FC = () => { { zIndex: { xs: (theme) => theme.zIndex.drawer + 2, sm: 'auto' }, height: { xs: '100vh', sm: '100vh' }, display: 'flex', - flexDirection: 'column', + flexDirection: 'row', flexShrink: 0, - transition: TRANSITION, overflow: 'hidden', }} > - + {/* Drag handle on left edge */} - - Chat - - - {nextcloudUrl && ( - - safeOpenUrl(`${nextcloudUrl}/apps/spreed`)} aria-label="In Nextcloud öffnen"> - - - - )} - setChatPanelOpen(false)} aria-label="Chat einklappen"> - - - - - - {!connected ? ( - - - Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen. + /> + + + + + Chat + + {nextcloudUrl && ( + + safeOpenUrl(`${nextcloudUrl}/apps/spreed`)} aria-label="In Nextcloud öffnen"> + + + + )} + setChatPanelOpen(false)} aria-label="Chat einklappen"> + + + - ) : selectedRoomToken ? ( - - {/* Compact room sidebar — hidden on mobile */} - - {rooms.map((room) => { - const isSelected = room.token === selectedRoomToken; - return ( - - selectRoom(room.token)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.75, - px: 1, - py: 0.5, - cursor: 'pointer', - borderRadius: 1, - mx: 0.5, - bgcolor: isSelected ? 'action.selected' : 'transparent', - '&:hover': { - bgcolor: isSelected ? 'action.selected' : 'action.hover', - }, - }} - > - - - {room.displayName.substring(0, 2).toUpperCase()} - - - + + Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen. + + + ) : selectedRoomToken ? ( + + {/* Compact room sidebar — hidden on mobile */} + + {rooms.map((room) => { + const isSelected = room.token === selectedRoomToken; + return ( + + selectRoom(room.token)} sx={{ - flex: 1, - minWidth: 0, - fontWeight: isSelected ? 600 : 400, + display: 'flex', + alignItems: 'center', + gap: 0.75, + px: 1, + py: 0.5, + cursor: 'pointer', + borderRadius: 1, + mx: 0.5, + bgcolor: isSelected ? 'action.selected' : 'transparent', + '&:hover': { + bgcolor: isSelected ? 'action.selected' : 'action.hover', + }, }} > - {room.displayName} - - - - ); - })} + + + {room.displayName.substring(0, 2).toUpperCase()} + + + + {room.displayName} + + + + ); + })} + + + + - - - - - ) : ( - - )} + ) : ( + + )} + ); }; diff --git a/frontend/src/components/chat/ChatRoomList.tsx b/frontend/src/components/chat/ChatRoomList.tsx index 1e8c92f..abc370d 100644 --- a/frontend/src/components/chat/ChatRoomList.tsx +++ b/frontend/src/components/chat/ChatRoomList.tsx @@ -8,8 +8,7 @@ import ListItemText from '@mui/material/ListItemText'; import Avatar from '@mui/material/Avatar'; import Badge from '@mui/material/Badge'; import Typography from '@mui/material/Typography'; -import Tooltip from '@mui/material/Tooltip'; -import IconButton from '@mui/material/IconButton'; +import Fab from '@mui/material/Fab'; import AddIcon from '@mui/icons-material/Add'; import { useQueryClient } from '@tanstack/react-query'; import { useChat } from '../../contexts/ChatContext'; @@ -21,16 +20,11 @@ const ChatRoomList: React.FC = () => { const [dialogOpen, setDialogOpen] = useState(false); return ( - - - - GESPR\u00C4CHE + + + + GESPRÄCHE - - setDialogOpen(true)}> - - - {rooms.map((room) => { @@ -93,6 +87,18 @@ const ChatRoomList: React.FC = () => { ); })} + {/* Sticky FAB at bottom-right */} + + setDialogOpen(true)} + sx={{ pointerEvents: 'auto' }} + > + + + setDialogOpen(false)} diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx index 0083bf2..64be13c 100644 --- a/frontend/src/components/dashboard/DashboardLayout.tsx +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -14,7 +14,7 @@ interface DashboardLayoutProps { function DashboardLayoutInner({ children }: DashboardLayoutProps) { const [mobileOpen, setMobileOpen] = useState(false); const { isLoading } = useAuth(); - const { sidebarCollapsed, chatPanelOpen } = useLayout(); + const { sidebarCollapsed, chatPanelOpen, chatPanelWidth } = useLayout(); const handleDrawerToggle = () => { setMobileOpen(!mobileOpen); @@ -25,7 +25,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) { } const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH; - const chatWidth = chatPanelOpen ? 360 : 64; + const chatWidth = chatPanelOpen ? chatPanelWidth : 64; return ( diff --git a/frontend/src/components/shared/ChatAwareFab.tsx b/frontend/src/components/shared/ChatAwareFab.tsx index bd94706..c62680c 100644 --- a/frontend/src/components/shared/ChatAwareFab.tsx +++ b/frontend/src/components/shared/ChatAwareFab.tsx @@ -18,7 +18,7 @@ interface ChatAwareFabProps { */ const ChatAwareFab = React.forwardRef( ({ onClick, children, color = 'primary', size, 'aria-label': ariaLabel, sx }, ref) => { - const { chatPanelOpen } = useLayout(); + const { chatPanelOpen, chatPanelWidth } = useLayout(); return ( ( { position: 'fixed', bottom: 32, - right: chatPanelOpen ? 376 : 80, + right: chatPanelOpen ? chatPanelWidth + 16 : 80, transition: 'right 225ms cubic-bezier(0.4, 0, 0.6, 1)', }, ...(Array.isArray(sx) ? sx : sx ? [sx] : []), diff --git a/frontend/src/contexts/LayoutContext.tsx b/frontend/src/contexts/LayoutContext.tsx index 6b2aad0..b2ab45b 100644 --- a/frontend/src/contexts/LayoutContext.tsx +++ b/frontend/src/contexts/LayoutContext.tsx @@ -2,13 +2,17 @@ import React, { createContext, useContext, useState, useCallback, ReactNode } fr export const DRAWER_WIDTH = 240; export const DRAWER_WIDTH_COLLAPSED = 64; +export const CHAT_PANEL_MIN_WIDTH = 360; +export const CHAT_PANEL_MAX_WIDTH = 720; interface LayoutContextType { sidebarCollapsed: boolean; chatPanelOpen: boolean; + chatPanelWidth: number; toggleSidebar: () => void; toggleChatPanel: () => void; setChatPanelOpen: (open: boolean) => void; + setChatPanelWidth: (width: number) => void; } const LayoutContext = createContext(undefined); @@ -22,6 +26,21 @@ function getInitialCollapsed(): boolean { } } +function getInitialChatPanelWidth(): number { + try { + const stored = localStorage.getItem('chat-panel-width'); + if (stored) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed)) { + return Math.min(Math.max(parsed, CHAT_PANEL_MIN_WIDTH), CHAT_PANEL_MAX_WIDTH); + } + } + } catch { + // ignore storage errors + } + return CHAT_PANEL_MIN_WIDTH; +} + interface LayoutProviderProps { children: ReactNode; } @@ -29,6 +48,7 @@ interface LayoutProviderProps { export const LayoutProvider: React.FC = ({ children }) => { const [sidebarCollapsed, setSidebarCollapsed] = useState(getInitialCollapsed); const [chatPanelOpen, setChatPanelOpenState] = useState(false); + const [chatPanelWidth, setChatPanelWidthState] = useState(getInitialChatPanelWidth); const toggleSidebar = useCallback(() => { setSidebarCollapsed((prev) => { @@ -50,12 +70,24 @@ export const LayoutProvider: React.FC = ({ children }) => { setChatPanelOpenState(open); }, []); + const setChatPanelWidth = useCallback((width: number) => { + const clamped = Math.min(Math.max(width, CHAT_PANEL_MIN_WIDTH), CHAT_PANEL_MAX_WIDTH); + setChatPanelWidthState(clamped); + try { + localStorage.setItem('chat-panel-width', String(clamped)); + } catch { + // ignore storage errors + } + }, []); + const value: LayoutContextType = { sidebarCollapsed, chatPanelOpen, + chatPanelWidth, toggleSidebar, toggleChatPanel, setChatPanelOpen, + setChatPanelWidth, }; return {children}; @@ -68,3 +100,4 @@ export const useLayout = (): LayoutContextType => { } return context; }; +