rework internal order system

This commit is contained in:
Matthias Hochmeister
2026-03-24 13:28:46 +01:00
parent 50d963120a
commit 9a52e41372
9 changed files with 198 additions and 33 deletions

View File

@@ -407,6 +407,33 @@ class AusruestungsanfrageController {
} }
} }
// -------------------------------------------------------------------------
// Position delivery tracking
// -------------------------------------------------------------------------
async updatePositionGeliefert(req: Request, res: Response): Promise<void> {
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 // Overview
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

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

View File

@@ -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.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController));
router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.deleteRequest.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 // Linking requests to orders
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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( async function updateRequestStatus(
id: number, id: number,
status: string, status: string,
@@ -582,6 +609,7 @@ async function updateRequestStatus(
bearbeitetVon?: string, bearbeitetVon?: string,
) { ) {
// Use aktualisiert_am (always exists) + try bearbeitet_am (added in migration 050) // Use aktualisiert_am (always exists) + try bearbeitet_am (added in migration 050)
let updated;
try { try {
const result = await pool.query( const result = await pool.query(
`UPDATE ausruestung_anfragen `UPDATE ausruestung_anfragen
@@ -594,7 +622,7 @@ async function updateRequestStatus(
RETURNING *`, RETURNING *`,
[status, adminNotizen || null, bearbeitetVon || null, id], [status, adminNotizen || null, bearbeitetVon || null, id],
); );
return result.rows[0] || null; updated = result.rows[0] || null;
} catch { } catch {
// Fallback if bearbeitet_am column doesn't exist yet (migration 050 not run) // Fallback if bearbeitet_am column doesn't exist yet (migration 050 not run)
const result = await pool.query( const result = await pool.query(
@@ -607,8 +635,20 @@ async function updateRequestStatus(
RETURNING *`, RETURNING *`,
[status, adminNotizen || null, bearbeitetVon || null, id], [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) { async function deleteRequest(id: number) {
@@ -716,6 +756,7 @@ export default {
getRequests, getRequests,
getMyRequests, getMyRequests,
getRequestById, getRequestById,
updatePositionGeliefert,
createRequest, createRequest,
updateRequest, updateRequest,
updateRequestStatus, updateRequestStatus,

View File

@@ -120,7 +120,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
<IconButton <IconButton
size="small" size="small"
onClick={() => onReplyClick(message)} onClick={() => 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 }}
> >
<ReplyIcon sx={{ fontSize: '0.9rem', transform: 'scaleX(-1)' }} /> <ReplyIcon sx={{ fontSize: '0.9rem', transform: 'scaleX(-1)' }} />
</IconButton> </IconButton>
@@ -212,16 +212,15 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
</Typography> </Typography>
</Paper> </Paper>
{/* Reactions — shown on hover or when reactions exist */} {/* Reactions — always rendered to avoid layout shift */}
{(hovered || hasReactions) && (
<MessageReactions <MessageReactions
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) */} {/* Reply button for other messages (shown on right) */}
@@ -230,7 +229,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
<IconButton <IconButton
size="small" size="small"
onClick={() => onReplyClick(message)} onClick={() => 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 }}
> >
<ReplyIcon sx={{ fontSize: '0.9rem' }} /> <ReplyIcon sx={{ fontSize: '0.9rem' }} />
</IconButton> </IconButton>

View File

@@ -4,17 +4,43 @@ import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Popover from '@mui/material/Popover'; 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 AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
import { nextcloudApi } from '../../services/nextcloud'; import { nextcloudApi } from '../../services/nextcloud';
const QUICK_REACTIONS = ['\u{1F44D}', '\u2764\uFE0F', '\u{1F602}', '\u{1F62E}', '\u{1F622}', '\u{1F389}']; 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 { interface MessageReactionsProps {
token: string; token: string;
messageId: number; messageId: number;
reactions: Record<string, number>; reactions: Record<string, number>;
reactionsSelf: string[]; reactionsSelf: string[];
onReactionToggled: () => void; onReactionToggled: () => void;
visible?: boolean;
} }
const MessageReactions: React.FC<MessageReactionsProps> = ({ const MessageReactions: React.FC<MessageReactionsProps> = ({
@@ -23,6 +49,7 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
reactions, reactions,
reactionsSelf, reactionsSelf,
onReactionToggled, onReactionToggled,
visible = true,
}) => { }) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
@@ -46,7 +73,17 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
const hasReactions = Object.keys(reactions).length > 0; const hasReactions = Object.keys(reactions).length > 0;
return ( return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.25, mt: 0.25, alignItems: 'center' }}> <Box sx={{
display: 'flex',
flexWrap: 'wrap',
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]) => ( {hasReactions && Object.entries(reactions).map(([emoji, count]) => (
<Chip <Chip
key={emoji} key={emoji}
@@ -81,7 +118,9 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
anchorOrigin={{ vertical: 'top', horizontal: 'left' }} anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
> >
<Box sx={{ display: 'flex', p: 0.5, gap: 0.25 }}> <Box sx={{ p: 0.5, maxWidth: 280 }}>
{/* Quick reactions row */}
<Box sx={{ display: 'flex', gap: 0.25 }}>
{QUICK_REACTIONS.map((emoji) => ( {QUICK_REACTIONS.map((emoji) => (
<IconButton <IconButton
key={emoji} key={emoji}
@@ -93,6 +132,30 @@ const MessageReactions: React.FC<MessageReactionsProps> = ({
</IconButton> </IconButton>
))} ))}
</Box> </Box>
<Divider sx={{ my: 0.5 }} />
{/* Extended emoji grid */}
<Box sx={{ maxHeight: 200, overflowY: 'auto' }}>
{EXTENDED_EMOJI_CATEGORIES.map((cat) => (
<Box key={cat.label}>
<Typography variant="caption" color="text.secondary" sx={{ px: 0.5, display: 'block', mt: 0.5 }}>
{cat.label}
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)' }}>
{cat.emojis.map((emoji) => (
<IconButton
key={emoji}
size="small"
onClick={() => { handleToggle(emoji); setAnchorEl(null); }}
sx={{ fontSize: '1rem', minWidth: 28, minHeight: 28, p: 0.25 }}
>
{emoji}
</IconButton>
))}
</Box>
</Box>
))}
</Box>
</Box>
</Popover> </Popover>
</Box> </Box>
); );

View File

@@ -49,7 +49,7 @@ interface EigenschaftFieldsProps {
function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) { function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) {
if (eigenschaften.length === 0) return null; if (eigenschaften.length === 0) return null;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ml: 2, mt: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ml: 2, mt: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
{eigenschaften.map(e => ( {eigenschaften.map(e => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{e.typ === 'options' && e.optionen && e.optionen.length > 0 ? ( {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'), 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 = () => { const startEditing = () => {
if (!detail) return; if (!detail) return;
setEditBezeichnung(detail.anfrage.bezeichnung || ''); setEditBezeichnung(detail.anfrage.bezeichnung || '');
@@ -522,7 +531,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
<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 && (
<Box> <Box>
<Typography variant="caption" color="text.secondary">Anfrager</Typography> <Typography variant="caption" color="text.secondary">Anfrage r</Typography>
<Typography variant="body2" fontWeight={500}>{anfrage!.anfrager_name}</Typography> <Typography variant="body2" fontWeight={500}>{anfrage!.anfrager_name}</Typography>
</Box> </Box>
)} )}
@@ -558,6 +567,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
{showAdminActions && <TableCell padding="checkbox">Geliefert</TableCell>}
<TableCell>Artikel</TableCell> <TableCell>Artikel</TableCell>
<TableCell align="right">Menge</TableCell> <TableCell align="right">Menge</TableCell>
<TableCell>Details</TableCell> <TableCell>Details</TableCell>
@@ -565,9 +575,19 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can
</TableHead> </TableHead>
<TableBody> <TableBody>
{detail.positionen.map(p => ( {detail.positionen.map(p => (
<TableRow key={p.id}> <TableRow key={p.id} sx={p.geliefert ? { opacity: 0.5 } : undefined}>
{showAdminActions && (
<TableCell padding="checkbox">
<Checkbox
size="small"
checked={p.geliefert}
disabled={geliefertMut.isPending}
onChange={(_, checked) => geliefertMut.mutate({ positionId: p.id, geliefert: checked })}
/>
</TableCell>
)}
<TableCell> <TableCell>
<Typography variant="body2" fontWeight={500}>{p.bezeichnung}</Typography> <Typography variant="body2" fontWeight={500} sx={p.geliefert ? { textDecoration: 'line-through' } : undefined}>{p.bezeichnung}</Typography>
{p.eigenschaften && p.eigenschaften.length > 0 && ( {p.eigenschaften && p.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{p.eigenschaften.map(e => ( {p.eigenschaften.map(e => (
@@ -1118,7 +1138,7 @@ function MeineAnfragenTab() {
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Anfrage</TableCell> <TableCell>Anfrage ID</TableCell>
<TableCell>Bezeichnung</TableCell> <TableCell>Bezeichnung</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Positionen</TableCell> <TableCell>Positionen</TableCell>
@@ -1357,9 +1377,9 @@ function AlleAnfragenTab() {
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Anfrage</TableCell> <TableCell>Anfrage ID</TableCell>
<TableCell>Bezeichnung</TableCell> <TableCell>Bezeichnung</TableCell>
<TableCell>Anfrager</TableCell> <TableCell>Anfrage r</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell>Positionen</TableCell> <TableCell>Positionen</TableCell>
<TableCell>Erstellt am</TableCell> <TableCell>Erstellt am</TableCell>

View File

@@ -113,6 +113,11 @@ export const ausruestungsanfrageApi = {
await api.delete(`/api/ausruestungsanfragen/requests/${id}`); await api.delete(`/api/ausruestungsanfragen/requests/${id}`);
}, },
// ── Position delivery tracking ──
updatePositionGeliefert: async (positionId: number, geliefert: boolean): Promise<void> => {
await api.patch(`/api/ausruestungsanfragen/positionen/${positionId}/geliefert`, { geliefert });
},
// ── Linking ── // ── Linking ──
linkToOrder: async (anfrageId: number, bestellungId: number): Promise<void> => { linkToOrder: async (anfrageId: number, bestellungId: number): Promise<void> => {
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId }); await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/link`, { bestellung_id: bestellungId });

View File

@@ -100,6 +100,7 @@ export interface AusruestungAnfragePosition {
bezeichnung: string; bezeichnung: string;
menge: number; menge: number;
notizen?: string; notizen?: string;
geliefert: boolean;
erstellt_am: string; erstellt_am: string;
eigenschaften?: AusruestungPositionEigenschaft[]; eigenschaften?: AusruestungPositionEigenschaft[];
} }