rework internal order system

This commit is contained in:
Matthias Hochmeister
2026-03-24 14:02:16 +01:00
parent 90944ca5f6
commit abb337c683
9 changed files with 261 additions and 75 deletions

View File

@@ -208,6 +208,20 @@ class AusruestungsanfrageController {
} }
} }
// -------------------------------------------------------------------------
// Users (for "order on behalf of" autocomplete)
// -------------------------------------------------------------------------
async getAllUsers(_req: Request, res: Response): Promise<void> {
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 // Requests
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -251,11 +265,12 @@ class AusruestungsanfrageController {
async createRequest(req: Request, res: Response): Promise<void> { async createRequest(req: Request, res: Response): Promise<void> {
try { 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 }[] }[]; items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
notizen?: string; notizen?: string;
bezeichnung?: string; bezeichnung?: string;
fuer_benutzer_id?: string; fuer_benutzer_id?: string;
fuer_benutzer_name?: string;
}; };
if (!items || items.length === 0) { if (!items || items.length === 0) {
@@ -276,6 +291,7 @@ class AusruestungsanfrageController {
// Determine anfrager: self or on behalf of another user // Determine anfrager: self or on behalf of another user
let anfragerId = req.user!.id; let anfragerId = req.user!.id;
let storedFuerBenutzerName: string | undefined;
if (fuer_benutzer_id && fuer_benutzer_id !== req.user!.id) { if (fuer_benutzer_id && fuer_benutzer_id !== req.user!.id) {
const groups = req.user?.groups ?? []; const groups = req.user?.groups ?? [];
const canOrderForUser = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:order_for_user'); const canOrderForUser = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:order_for_user');
@@ -284,9 +300,18 @@ class AusruestungsanfrageController {
return; return;
} }
anfragerId = fuer_benutzer_id; 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 }); res.status(201).json({ success: true, data: request });
} catch (error) { } catch (error) {
logger.error('AusruestungsanfrageController.createRequest error', { error }); logger.error('AusruestungsanfrageController.createRequest error', { error });

View File

@@ -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;

View File

@@ -32,6 +32,12 @@ router.delete('/eigenschaften/:eigenschaftId', authenticate, requirePermission('
// Legacy text-based categories // Legacy text-based categories
router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController)); 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 // Overview
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -266,6 +266,20 @@ async function deleteArtikelEigenschaft(id: number) {
await pool.query('DELETE FROM ausruestung_artikel_eigenschaften WHERE id = $1', [id]); 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) // Requests (ausruestung_anfragen)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -288,6 +302,7 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
`SELECT a.*, `SELECT a.*,
COALESCE(u.given_name || ' ' || u.family_name, u.name) AS anfrager_name, 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,
(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) AS positionen_count,
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_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 FROM ausruestung_anfragen a
@@ -316,7 +331,8 @@ async function getRequestById(id: number) {
const reqResult = await pool.query( const reqResult = await pool.query(
`SELECT a.*, `SELECT a.*,
COALESCE(u.given_name || ' ' || u.family_name, u.name) AS anfrager_name, 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 FROM ausruestung_anfragen a
LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u ON u.id = a.anfrager_id
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von 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 }[] }[], items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[],
notizen?: string, notizen?: string,
bezeichnung?: string, bezeichnung?: string,
fuerBenutzerName?: string,
) { ) {
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -407,20 +424,20 @@ async function createRequest(
); );
const nextNr = maxResult.rows[0].next_nr; const nextNr = maxResult.rows[0].next_nr;
const anfrageResult = await client.query( const anfrageResult = await client.query(
`INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, bestell_nummer, bestell_jahr) `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, bestell_nummer, bestell_jahr, fuer_benutzer_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`, 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'); await client.query('RELEASE SAVEPOINT sp_bestell_nr');
anfrage = anfrageResult.rows[0]; anfrage = anfrageResult.rows[0];
} catch { } catch {
await client.query('ROLLBACK TO SAVEPOINT sp_bestell_nr'); await client.query('ROLLBACK TO SAVEPOINT sp_bestell_nr');
const anfrageResult = await client.query( const anfrageResult = await client.query(
`INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung) `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, fuer_benutzer_name)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING *`, RETURNING *`,
[userId, notizen || null, bezeichnung || null], [userId, notizen || null, bezeichnung || null, fuerBenutzerName || null],
); );
anfrage = anfrageResult.rows[0]; anfrage = anfrageResult.rows[0];
} }
@@ -752,6 +769,7 @@ async function getWidgetOverview() {
} }
export default { export default {
getAllUsers,
getKategorien, getKategorien,
createKategorie, createKategorie,
updateKategorie, updateKategorie,

View File

@@ -10,7 +10,7 @@ import type { NextcloudMessage } from '../../types/nextcloud.types';
import FileMessageContent from './FileMessageContent'; import FileMessageContent from './FileMessageContent';
import RichMessageText from './RichMessageText'; import RichMessageText from './RichMessageText';
import PollMessageContent from './PollMessageContent'; import PollMessageContent from './PollMessageContent';
import MessageReactions from './MessageReactions'; import { ReactionChips, AddReactionButton } from './MessageReactions';
const SENDER_COLORS = [ const SENDER_COLORS = [
'#E53935', '#D81B60', '#8E24AA', '#5E35B1', '#E53935', '#D81B60', '#8E24AA', '#5E35B1',
@@ -44,9 +44,24 @@ function hasPollParam(params?: Record<string, any>): boolean {
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneToOne, onReplyClick, onReactionToggled, reactionsOverride }) => { const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneToOne, onReplyClick, onReactionToggled, reactionsOverride }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const hoverDelayTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimer = useRef<ReturnType<typeof setTimeout> | 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 = () => { const handleTouchStart = () => {
longPressTimer.current = setTimeout(() => { longPressTimer.current = setTimeout(() => {
setHovered(true); setHovered(true);
@@ -73,6 +88,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
} }
setHovered(false); setHovered(false);
}; };
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', { const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
@@ -96,7 +112,43 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
const reactions = reactionsOverride?.reactions ?? message.reactions ?? {}; const reactions = reactionsOverride?.reactions ?? message.reactions ?? {};
const reactionsSelf = reactionsOverride?.reactionsSelf ?? message.reactionsSelf ?? []; const reactionsSelf = reactionsOverride?.reactionsSelf ?? message.reactionsSelf ?? [];
const hasReactions = Object.keys(reactions).length > 0;
/* Vertical toolbar with reply + add-reaction */
const toolbar = (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
opacity: hovered ? 0.7 : 0,
visibility: hovered ? 'visible' : 'hidden',
transition: 'opacity 0.15s, visibility 0.15s',
alignSelf: 'center',
flexShrink: 0,
}}
>
{onReplyClick && (
<Tooltip title="Antworten">
<IconButton
size="small"
onClick={() => onReplyClick(message)}
sx={{ width: 24, height: 24 }}
>
<ReplyIcon sx={{ fontSize: '0.9rem', transform: isOwnMessage ? 'scaleX(-1)' : undefined }} />
</IconButton>
</Tooltip>
)}
{onReactionToggled && (
<AddReactionButton
token={message.token}
messageId={message.id}
reactionsSelf={reactionsSelf}
onReactionToggled={() => onReactionToggled(message.id)}
/>
)}
</Box>
);
return ( return (
<Box <Box
@@ -105,27 +157,17 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
justifyContent: isOwnMessage ? 'flex-end' : 'flex-start', justifyContent: isOwnMessage ? 'flex-end' : 'flex-start',
my: 0.5, my: 0.5,
px: 1, px: 1,
alignItems: 'flex-end', alignItems: 'flex-start',
gap: 0.5, gap: 0.5,
}} }}
onMouseEnter={() => setHovered(true)} onMouseEnter={handleMouseEnter}
onMouseLeave={() => setHovered(false)} onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
> >
{/* Reply button for own messages (shown on left) */} {/* Own messages: toolbar on LEFT */}
{isOwnMessage && onReplyClick && ( {isOwnMessage && toolbar}
<Tooltip title="Antworten">
<IconButton
size="small"
onClick={() => onReplyClick(message)}
sx={{ opacity: hovered ? 0.7 : 0, visibility: hovered ? 'visible' : 'hidden', transition: 'opacity 0.15s, visibility 0.15s', mb: 0.5 }}
>
<ReplyIcon sx={{ fontSize: '0.9rem', transform: 'scaleX(-1)' }} />
</IconButton>
</Tooltip>
)}
<Box sx={{ maxWidth: '80%' }}> <Box sx={{ maxWidth: '80%' }}>
<Paper <Paper
@@ -212,29 +254,18 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
</Typography> </Typography>
</Paper> </Paper>
{/* Reactions — always rendered to avoid layout shift */} {/* Reaction chips below the bubble */}
<MessageReactions <ReactionChips
token={message.token} token={message.token}
messageId={message.id} messageId={message.id}
reactions={reactions} reactions={reactions}
reactionsSelf={reactionsSelf} reactionsSelf={reactionsSelf}
onReactionToggled={() => onReactionToggled?.(message.id)} onReactionToggled={() => onReactionToggled?.(message.id)}
visible={hovered || hasReactions}
/> />
</Box> </Box>
{/* Reply button for other messages (shown on right) */} {/* Partner messages: toolbar on RIGHT */}
{!isOwnMessage && onReplyClick && ( {!isOwnMessage && toolbar}
<Tooltip title="Antworten">
<IconButton
size="small"
onClick={() => onReplyClick(message)}
sx={{ opacity: hovered ? 0.7 : 0, visibility: hovered ? 'visible' : 'hidden', transition: 'opacity 0.15s, visibility 0.15s', mb: 0.5 }}
>
<ReplyIcon sx={{ fontSize: '0.9rem' }} />
</IconButton>
</Tooltip>
)}
</Box> </Box>
); );
}; };

View File

@@ -34,24 +34,16 @@ const EXTENDED_EMOJI_CATEGORIES: { label: string; emojis: string[] }[] = [
}, },
]; ];
interface MessageReactionsProps { /* ------------------------------------------------------------------ */
token: string; /* Shared toggle helper */
messageId: number; /* ------------------------------------------------------------------ */
reactions: Record<string, number>;
reactionsSelf: string[];
onReactionToggled: () => void;
visible?: boolean;
}
const MessageReactions: React.FC<MessageReactionsProps> = ({ function useReactionToggle(
token, token: string,
messageId, messageId: number,
reactions, reactionsSelf: string[],
reactionsSelf, onReactionToggled: () => void,
onReactionToggled, ) {
visible = true,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const handleToggle = async (emoji: string) => { const handleToggle = async (emoji: string) => {
@@ -70,8 +62,33 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
} }
}; };
return { loading, handleToggle };
}
/* ------------------------------------------------------------------ */
/* ReactionChips — existing reactions rendered below the bubble */
/* ------------------------------------------------------------------ */
interface ReactionChipsProps {
token: string;
messageId: number;
reactions: Record<string, number>;
reactionsSelf: string[];
onReactionToggled: () => void;
}
const ReactionChips: React.FC<ReactionChipsProps> = ({
token,
messageId,
reactions,
reactionsSelf,
onReactionToggled,
}) => {
const { loading, handleToggle } = useReactionToggle(token, messageId, reactionsSelf, onReactionToggled);
const hasReactions = Object.keys(reactions).length > 0; const hasReactions = Object.keys(reactions).length > 0;
if (!hasReactions) return null;
return ( return (
<Box sx={{ <Box sx={{
display: 'flex', display: 'flex',
@@ -79,12 +96,8 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
gap: 0.25, gap: 0.25,
mt: 0.25, mt: 0.25,
alignItems: 'center', 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]) => (
<Chip <Chip
key={emoji} key={emoji}
label={`${emoji} ${count}`} label={`${emoji} ${count}`}
@@ -102,11 +115,37 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
}} }}
/> />
))} ))}
</Box>
);
};
/* ------------------------------------------------------------------ */
/* AddReactionButton — icon button + emoji popover */
/* ------------------------------------------------------------------ */
interface AddReactionButtonProps {
token: string;
messageId: number;
reactionsSelf: string[];
onReactionToggled: () => void;
}
const AddReactionButton: React.FC<AddReactionButtonProps> = ({
token,
messageId,
reactionsSelf,
onReactionToggled,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const { handleToggle } = useReactionToggle(token, messageId, reactionsSelf, onReactionToggled);
return (
<>
<Tooltip title="Reaktion hinzuf\u00FCgen"> <Tooltip title="Reaktion hinzuf\u00FCgen">
<IconButton <IconButton
size="small" size="small"
onClick={(e) => setAnchorEl(e.currentTarget)} onClick={(e) => 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 } }}
> >
<AddReactionOutlinedIcon sx={{ fontSize: '0.85rem' }} /> <AddReactionOutlinedIcon sx={{ fontSize: '0.85rem' }} />
</IconButton> </IconButton>
@@ -157,8 +196,50 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
</Box> </Box>
</Box> </Box>
</Popover> </Popover>
</>
);
};
/* ------------------------------------------------------------------ */
/* Legacy default export — kept for backward compatibility */
/* (combines chips + add button, same interface as before) */
/* ------------------------------------------------------------------ */
interface MessageReactionsProps {
token: string;
messageId: number;
reactions: Record<string, number>;
reactionsSelf: string[];
onReactionToggled: () => void;
visible?: boolean;
}
const MessageReactions: React.FC<MessageReactionsProps> = (props) => {
const { visible = true, ...rest } = props;
const hasReactions = Object.keys(rest.reactions).length > 0;
return (
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.25,
mt: 0.25,
alignItems: 'center',
minHeight: hasReactions ? 24 : 0,
visibility: visible ? 'visible' : 'hidden',
opacity: visible ? 1 : 0,
transition: 'opacity 0.15s, visibility 0.15s',
}}>
<ReactionChips {...rest} />
<AddReactionButton
token={rest.token}
messageId={rest.messageId}
reactionsSelf={rest.reactionsSelf}
onReactionToggled={rest.onReactionToggled}
/>
</Box> </Box>
); );
}; };
export { ReactionChips, AddReactionButton };
export default MessageReactions; export default MessageReactions;

View File

@@ -529,10 +529,14 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
<> <>
{/* Meta info */} {/* Meta info */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}> <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{anfrage!.anfrager_name && ( {(anfrage!.anfrager_name || anfrage!.fuer_benutzer_name) && (
<Box> <Box>
<Typography variant="caption" color="text.secondary">Anfrage für</Typography> <Typography variant="caption" color="text.secondary">Anfrage für</Typography>
<Typography variant="body2" fontWeight={500}>{anfrage!.anfrager_name}</Typography> <Typography variant="body2" fontWeight={500}>
{anfrage!.fuer_benutzer_name
? `${anfrage!.fuer_benutzer_name} (erstellt von ${anfrage!.anfrager_name || 'Unbekannt'})`
: anfrage!.anfrager_name}
</Typography>
</Box> </Box>
)} )}
<Box> <Box>
@@ -987,7 +991,7 @@ function MeineAnfragenTab() {
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newBezeichnung, setNewBezeichnung] = useState(''); const [newBezeichnung, setNewBezeichnung] = useState('');
const [newNotizen, setNewNotizen] = 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<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]); const [newItems, setNewItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
// Track loaded eigenschaften per item row (by artikel_id) // Track loaded eigenschaften per item row (by artikel_id)
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({}); const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
@@ -1016,8 +1020,8 @@ function MeineAnfragenTab() {
}); });
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string }) => 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), ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Anfrage erstellt'); showSuccess('Anfrage erstellt');
@@ -1085,7 +1089,8 @@ function MeineAnfragenTab() {
items: allItems, items: allItems,
notizen: newNotizen || undefined, notizen: newNotizen || undefined,
bezeichnung: newBezeichnung || 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 && ( {canOrderForUser && (
<Autocomplete <Autocomplete
freeSolo
options={orderUsers} options={orderUsers}
getOptionLabel={o => o.name} getOptionLabel={o => typeof o === 'string' ? o : o.name}
value={newFuerBenutzer} value={newFuerBenutzer}
onChange={(_, v) => setNewFuerBenutzer(v)} onChange={(_, v) => setNewFuerBenutzer(v)}
renderInput={params => <TextField {...params} label="Für wen (optional)" InputLabelProps={{ ...params.InputLabelProps, shrink: true }} placeholder="Mitglied auswählen..." />} 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 => <TextField {...params} label="Für wen (optional)" InputLabelProps={{ ...params.InputLabelProps, shrink: true }} placeholder="Mitglied auswählen oder Name eingeben..." />}
/> />
)} )}
<TextField <TextField
@@ -1391,7 +1412,7 @@ function AlleAnfragenTab() {
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setDetailId(r.id)}> <TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => setDetailId(r.id)}>
<TableCell>{formatOrderId(r)}</TableCell> <TableCell>{formatOrderId(r)}</TableCell>
<TableCell>{r.bezeichnung || '-'}</TableCell> <TableCell>{r.bezeichnung || '-'}</TableCell>
<TableCell>{r.anfrager_name || r.anfrager_id}</TableCell> <TableCell>{r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id}</TableCell>
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell> <TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell> <TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
<TableCell> <TableCell>

View File

@@ -94,8 +94,9 @@ export const ausruestungsanfrageApi = {
notizen?: string, notizen?: string,
bezeichnung?: string, bezeichnung?: string,
fuer_benutzer_id?: string, fuer_benutzer_id?: string,
fuer_benutzer_name?: string,
): Promise<AusruestungAnfrageDetailResponse> => { ): Promise<AusruestungAnfrageDetailResponse> => {
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; return r.data.data;
}, },
updateRequest: async ( updateRequest: async (
@@ -140,7 +141,7 @@ export const ausruestungsanfrageApi = {
// ── Users ── // ── Users ──
getOrderUsers: async (): Promise<Array<{ id: string; name: string }>> => { getOrderUsers: async (): Promise<Array<{ id: string; name: string }>> => {
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; return r.data.data;
}, },
}; };

View File

@@ -79,6 +79,7 @@ export interface AusruestungAnfrage {
id: number; id: number;
anfrager_id: string; anfrager_id: string;
anfrager_name?: string; anfrager_name?: string;
fuer_benutzer_name?: string;
bezeichnung?: string; bezeichnung?: string;
status: AusruestungAnfrageStatus; status: AusruestungAnfrageStatus;
notizen?: string; notizen?: string;