rework internal order system
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user