Features: - Automatic stock status updates based on return order outcomes - Repair parts allocation and consumption tracking - Configurable status mappings for each outcome type - React-based UI panel for managing repair parts - Location display for easy part retrieval - Available stock filtering (excludes allocated items)
500 lines
22 KiB
JavaScript
500 lines
22 KiB
JavaScript
/**
|
||
* Repair Parts Panel for RMA Automation Plugin
|
||
* React-based implementation using InvenTree's native components and form system
|
||
*/
|
||
|
||
// Format quantity - show whole number if no decimal, otherwise show decimal
|
||
function formatQty(value) {
|
||
if (value == null) return '0';
|
||
const num = parseFloat(value);
|
||
return Number.isInteger(num) ? num.toString() : num.toFixed(2).replace(/\.?0+$/, '');
|
||
}
|
||
|
||
// API helper using the context's api instance or fetch fallback
|
||
async function apiFetch(ctx, url, options = {}) {
|
||
// If we have the InvenTree api instance, use it
|
||
if (ctx?.api) {
|
||
try {
|
||
const response = await ctx.api({
|
||
url: url,
|
||
method: options.method || 'GET',
|
||
data: options.body ? JSON.parse(options.body) : undefined,
|
||
});
|
||
return response.data;
|
||
} catch (error) {
|
||
throw new Error(error.response?.data?.detail || error.message || 'Request failed');
|
||
}
|
||
}
|
||
|
||
// Fallback to fetch
|
||
const response = await fetch(url, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': getCsrfToken(),
|
||
...options.headers,
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||
throw new Error(error.detail || JSON.stringify(error));
|
||
}
|
||
if (response.status === 204) return null;
|
||
return response.json();
|
||
}
|
||
|
||
function getCsrfToken() {
|
||
const name = 'csrftoken';
|
||
let cookieValue = null;
|
||
if (document.cookie && document.cookie !== '') {
|
||
const cookies = document.cookie.split(';');
|
||
for (let i = 0; i < cookies.length; i++) {
|
||
const cookie = cookies[i].trim();
|
||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return cookieValue;
|
||
}
|
||
|
||
// Main Panel Component
|
||
function RepairPartsPanel({ ctx }) {
|
||
const React = window.React;
|
||
const { useState, useEffect, useCallback, useMemo } = React;
|
||
const { Button, Table, Text, Group, Badge, Stack, Loader, Alert, Paper, ActionIcon, Tooltip } = window.MantineCore;
|
||
const IconTrash = window.TablerIcons?.IconTrash;
|
||
|
||
const [allocations, setAllocations] = useState([]);
|
||
const [lines, setLines] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [showForm, setShowForm] = useState(false);
|
||
|
||
const returnOrderId = ctx.id;
|
||
const isComplete = ctx.instance?.status === 30;
|
||
|
||
// Fetch data
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const [allocData, linesData] = await Promise.all([
|
||
apiFetch(ctx, `/plugin/rma-automation/allocations/?return_order=${returnOrderId}`),
|
||
apiFetch(ctx, `/api/order/ro-line/?order=${returnOrderId}`),
|
||
]);
|
||
setAllocations(allocData || []);
|
||
setLines(linesData?.results || linesData || []);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [returnOrderId]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
// Delete allocation
|
||
const handleDelete = async (id) => {
|
||
if (!confirm('Remove this repair part allocation?')) return;
|
||
try {
|
||
await apiFetch(ctx, `/plugin/rma-automation/allocations/${id}/`, { method: 'DELETE' });
|
||
fetchData();
|
||
} catch (err) {
|
||
alert('Failed to remove: ' + err.message);
|
||
}
|
||
};
|
||
|
||
// Handle form success
|
||
const handleFormSuccess = () => {
|
||
setShowForm(false);
|
||
fetchData();
|
||
};
|
||
|
||
if (loading) {
|
||
return React.createElement(Group, { justify: 'center', p: 'xl' },
|
||
React.createElement(Loader, { size: 'sm' }),
|
||
React.createElement(Text, { size: 'sm', c: 'dimmed' }, 'Loading repair parts...')
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return React.createElement(Alert, { color: 'red', title: 'Error' }, error);
|
||
}
|
||
|
||
// Render delete button with or without icon
|
||
const renderDeleteButton = (allocId) => {
|
||
if (IconTrash) {
|
||
return React.createElement(Tooltip, { label: 'Remove allocation' },
|
||
React.createElement(ActionIcon, {
|
||
color: 'red',
|
||
variant: 'subtle',
|
||
onClick: () => handleDelete(allocId)
|
||
}, React.createElement(IconTrash, { size: 16 }))
|
||
);
|
||
}
|
||
return React.createElement(Button, {
|
||
color: 'red',
|
||
size: 'xs',
|
||
variant: 'subtle',
|
||
onClick: () => handleDelete(allocId)
|
||
}, '×');
|
||
};
|
||
|
||
return React.createElement(Stack, { gap: 'md' },
|
||
// Header with description and add button
|
||
React.createElement(Group, { justify: 'space-between', align: 'center' },
|
||
React.createElement(Text, { size: 'sm', c: 'dimmed' },
|
||
isComplete
|
||
? 'This order is complete. Allocated parts have been consumed.'
|
||
: 'Allocate stock items to be consumed when this return order is completed.'
|
||
),
|
||
!isComplete && React.createElement(Button, {
|
||
onClick: () => setShowForm(true),
|
||
size: 'compact-sm',
|
||
variant: 'light',
|
||
}, '+ Add Part')
|
||
),
|
||
|
||
// Allocations table
|
||
allocations.length === 0
|
||
? React.createElement(Paper, { p: 'lg', withBorder: true },
|
||
React.createElement(Text, { c: 'dimmed', ta: 'center', fs: 'italic' },
|
||
'No repair parts allocated yet.'
|
||
)
|
||
)
|
||
: React.createElement(Table, {
|
||
striped: true,
|
||
highlightOnHover: true,
|
||
withTableBorder: true,
|
||
verticalSpacing: 'sm',
|
||
},
|
||
React.createElement(Table.Thead, null,
|
||
React.createElement(Table.Tr, null,
|
||
React.createElement(Table.Th, { style: { width: '30%' } }, 'Repair Item'),
|
||
React.createElement(Table.Th, { style: { width: '30%' } }, 'Replacement Part'),
|
||
React.createElement(Table.Th, { style: { textAlign: 'center', width: '10%' } }, 'Qty'),
|
||
React.createElement(Table.Th, { style: { textAlign: 'center', width: '20%' } }, 'Status'),
|
||
!isComplete && React.createElement(Table.Th, { style: { width: '10%' } }, '')
|
||
)
|
||
),
|
||
React.createElement(Table.Tbody, null,
|
||
allocations.map(alloc => {
|
||
const detail = alloc.stock_item_detail || {};
|
||
const lineDetail = alloc.line_item_detail || {};
|
||
return React.createElement(Table.Tr, { key: alloc.id },
|
||
// Repair Item column - show the line item being repaired
|
||
React.createElement(Table.Td, null,
|
||
React.createElement(Stack, { gap: 0 },
|
||
React.createElement(Text, { size: 'sm', fw: 500 }, lineDetail.part_name || 'Unknown'),
|
||
lineDetail.serial && React.createElement(Text, { size: 'xs', c: 'dimmed' }, `SN: ${lineDetail.serial}`)
|
||
)
|
||
),
|
||
// Replacement Part column - show the stock item being consumed with location
|
||
React.createElement(Table.Td, null,
|
||
React.createElement(Stack, { gap: 0 },
|
||
React.createElement(Text, { size: 'sm' }, detail.part_name || 'Unknown'),
|
||
(detail.serial || detail.batch) && React.createElement(Text, { size: 'xs', c: 'dimmed' },
|
||
detail.serial ? `SN: ${detail.serial}` : `Batch: ${detail.batch}`
|
||
),
|
||
detail.location_name && React.createElement(Text, { size: 'xs', c: 'teal', fw: 500 },
|
||
detail.location_name
|
||
)
|
||
)
|
||
),
|
||
React.createElement(Table.Td, { style: { textAlign: 'center' } },
|
||
React.createElement(Text, { size: 'sm' }, formatQty(alloc.quantity))
|
||
),
|
||
React.createElement(Table.Td, { style: { textAlign: 'center' } },
|
||
React.createElement(Badge, {
|
||
color: alloc.consumed ? 'green' : 'blue',
|
||
variant: 'light',
|
||
size: 'sm'
|
||
}, alloc.consumed ? 'Consumed' : 'Allocated')
|
||
),
|
||
!isComplete && React.createElement(Table.Td, { style: { textAlign: 'center' } },
|
||
!alloc.consumed && renderDeleteButton(alloc.id)
|
||
)
|
||
);
|
||
})
|
||
)
|
||
),
|
||
|
||
// Add form modal
|
||
showForm && React.createElement(AddAllocationForm, {
|
||
ctx: ctx,
|
||
lines: lines,
|
||
returnOrderId: returnOrderId,
|
||
onSuccess: handleFormSuccess,
|
||
onCancel: () => setShowForm(false),
|
||
})
|
||
);
|
||
}
|
||
|
||
// Add Allocation Form Component (Modal)
|
||
function AddAllocationForm({ ctx, lines, returnOrderId, onSuccess, onCancel }) {
|
||
const React = window.React;
|
||
const { useState, useEffect } = React;
|
||
const { Modal, TextInput, Select, NumberInput, Button, Text, Group, Badge, Stack, Loader, Alert, Paper, ScrollArea, UnstyledButton, Box, Divider } = window.MantineCore;
|
||
|
||
const [selectedLine, setSelectedLine] = useState('');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState([]);
|
||
const [selectedPart, setSelectedPart] = useState(null);
|
||
const [stockItems, setStockItems] = useState([]);
|
||
const [selectedStock, setSelectedStock] = useState(null);
|
||
const [quantity, setQuantity] = useState(1);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [searching, setSearching] = useState(false);
|
||
const [loadingStock, setLoadingStock] = useState(false);
|
||
|
||
// Search parts
|
||
useEffect(() => {
|
||
if (searchQuery.length < 2) {
|
||
setSearchResults([]);
|
||
return;
|
||
}
|
||
const timer = setTimeout(async () => {
|
||
setSearching(true);
|
||
try {
|
||
const data = await apiFetch(ctx, `/api/part/?search=${encodeURIComponent(searchQuery)}&limit=20`);
|
||
setSearchResults(data?.results || data || []);
|
||
} catch (err) {
|
||
console.error('Search failed:', err);
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [searchQuery]);
|
||
|
||
// Load stock when part selected - only show available stock
|
||
useEffect(() => {
|
||
if (!selectedPart) {
|
||
setStockItems([]);
|
||
return;
|
||
}
|
||
setLoadingStock(true);
|
||
(async () => {
|
||
try {
|
||
// Filter for in_stock=true and available=true to exclude fully allocated stock
|
||
const data = await apiFetch(ctx, `/api/stock/?part=${selectedPart.pk}&in_stock=true&available=true&limit=50`);
|
||
const items = data?.results || data || [];
|
||
// Calculate available quantity for each item
|
||
const itemsWithAvailable = items.map(item => ({
|
||
...item,
|
||
available_quantity: Math.max(0, (item.quantity || 0) - (item.allocated || 0))
|
||
}));
|
||
// Filter out items with no available quantity
|
||
setStockItems(itemsWithAvailable.filter(item => item.available_quantity > 0));
|
||
} catch (err) {
|
||
console.error('Failed to load stock:', err);
|
||
} finally {
|
||
setLoadingStock(false);
|
||
}
|
||
})();
|
||
}, [selectedPart]);
|
||
|
||
// Submit
|
||
const handleSubmit = async () => {
|
||
if (!selectedLine || !selectedStock || !quantity) {
|
||
setError('Please fill in all fields');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
await apiFetch(ctx, '/plugin/rma-automation/allocations/', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
return_order_line: parseInt(selectedLine),
|
||
stock_item: selectedStock.pk,
|
||
quantity: quantity,
|
||
}),
|
||
});
|
||
onSuccess();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const lineOptions = lines.map(line => ({
|
||
value: String(line.pk),
|
||
label: `${line.item_detail?.part_detail?.name || 'Unknown'}${line.item_detail?.serial ? ` (SN: ${line.item_detail.serial})` : ''}`
|
||
}));
|
||
|
||
return React.createElement(Modal, {
|
||
opened: true,
|
||
onClose: onCancel,
|
||
title: 'Add Repair Part',
|
||
size: 'lg',
|
||
centered: true,
|
||
overlayProps: { backgroundOpacity: 0.55, blur: 3 },
|
||
},
|
||
React.createElement(Stack, { gap: 'md' },
|
||
// Step 1: Line item select
|
||
React.createElement(Select, {
|
||
label: 'Line Item Being Repaired',
|
||
description: 'Select which return order item this repair part is for',
|
||
placeholder: 'Select line item...',
|
||
data: lineOptions,
|
||
value: selectedLine,
|
||
onChange: setSelectedLine,
|
||
searchable: true,
|
||
required: true,
|
||
withAsterisk: true,
|
||
}),
|
||
|
||
React.createElement(Divider, { label: 'Repair Part Selection', labelPosition: 'center' }),
|
||
|
||
// Step 2: Part search
|
||
!selectedPart ? React.createElement(Stack, { gap: 'xs' },
|
||
React.createElement(TextInput, {
|
||
label: 'Search for Part',
|
||
placeholder: 'Type at least 2 characters to search...',
|
||
value: searchQuery,
|
||
onChange: (e) => setSearchQuery(e.target.value),
|
||
rightSection: searching ? React.createElement(Loader, { size: 'xs' }) : null,
|
||
}),
|
||
searchResults.length > 0 && React.createElement(Paper, { withBorder: true, p: 0, radius: 'sm' },
|
||
React.createElement(ScrollArea, { h: 200 },
|
||
searchResults.map((part, index) => React.createElement(React.Fragment, { key: part.pk },
|
||
React.createElement(UnstyledButton, {
|
||
onClick: () => {
|
||
setSelectedPart(part);
|
||
setSearchQuery('');
|
||
setSearchResults([]);
|
||
},
|
||
style: { display: 'block', width: '100%' },
|
||
},
|
||
React.createElement(Box, {
|
||
p: 'sm',
|
||
style: {
|
||
cursor: 'pointer',
|
||
transition: 'background-color 150ms ease',
|
||
},
|
||
onMouseEnter: (e) => e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-1)',
|
||
onMouseLeave: (e) => e.currentTarget.style.backgroundColor = 'transparent',
|
||
},
|
||
React.createElement(Text, { size: 'sm', fw: 500 }, part.full_name || part.name),
|
||
part.description && React.createElement(Text, { size: 'xs', c: 'dimmed', lineClamp: 1 }, part.description)
|
||
)
|
||
),
|
||
index < searchResults.length - 1 && React.createElement(Divider, null)
|
||
))
|
||
)
|
||
)
|
||
) : React.createElement(Paper, { withBorder: true, p: 'sm', radius: 'sm', bg: 'var(--mantine-color-blue-light)' },
|
||
React.createElement(Group, { justify: 'space-between' },
|
||
React.createElement(Stack, { gap: 0 },
|
||
React.createElement(Text, { size: 'xs', c: 'dimmed' }, 'Selected Part'),
|
||
React.createElement(Text, { fw: 500 }, selectedPart.full_name || selectedPart.name)
|
||
),
|
||
React.createElement(Button, {
|
||
onClick: () => {
|
||
setSelectedPart(null);
|
||
setSelectedStock(null);
|
||
setStockItems([]);
|
||
setQuantity(1);
|
||
},
|
||
variant: 'subtle',
|
||
size: 'xs',
|
||
}, 'Change')
|
||
)
|
||
),
|
||
|
||
// Step 3: Stock items
|
||
selectedPart && React.createElement(Stack, { gap: 'xs' },
|
||
React.createElement(Group, { justify: 'space-between' },
|
||
React.createElement(Text, { size: 'sm', fw: 500 }, 'Select Stock Item'),
|
||
React.createElement(Text, { size: 'xs', c: 'dimmed' }, 'Only unallocated stock shown')
|
||
),
|
||
loadingStock
|
||
? React.createElement(Group, { justify: 'center', p: 'md' },
|
||
React.createElement(Loader, { size: 'sm' })
|
||
)
|
||
: stockItems.length === 0
|
||
? React.createElement(Alert, { color: 'yellow', variant: 'light' }, 'No available stock for this part')
|
||
: React.createElement(Paper, { withBorder: true, p: 0, radius: 'sm' },
|
||
React.createElement(ScrollArea, { h: 200 },
|
||
stockItems.map((stock, index) => React.createElement(React.Fragment, { key: stock.pk },
|
||
React.createElement(UnstyledButton, {
|
||
onClick: () => {
|
||
setSelectedStock(stock);
|
||
setQuantity(Math.min(1, stock.available_quantity));
|
||
},
|
||
style: { display: 'block', width: '100%' },
|
||
},
|
||
React.createElement(Box, {
|
||
p: 'sm',
|
||
style: {
|
||
backgroundColor: selectedStock?.pk === stock.pk ? 'var(--mantine-color-blue-light)' : undefined,
|
||
cursor: 'pointer',
|
||
},
|
||
},
|
||
React.createElement(Group, { justify: 'space-between', wrap: 'nowrap' },
|
||
React.createElement(Stack, { gap: 0 },
|
||
React.createElement(Text, { size: 'sm', fw: selectedStock?.pk === stock.pk ? 600 : 400 },
|
||
stock.serial ? `SN: ${stock.serial}` :
|
||
stock.batch ? `Batch: ${stock.batch}` :
|
||
`Stock #${stock.pk}`
|
||
),
|
||
React.createElement(Text, { size: 'xs', c: 'dimmed' },
|
||
stock.location_detail?.pathstring || 'Unknown location'
|
||
)
|
||
),
|
||
React.createElement(Badge, {
|
||
color: 'green',
|
||
variant: 'light',
|
||
size: 'lg'
|
||
}, formatQty(stock.available_quantity))
|
||
)
|
||
)
|
||
),
|
||
index < stockItems.length - 1 && React.createElement(Divider, null)
|
||
))
|
||
)
|
||
)
|
||
),
|
||
|
||
// Step 4: Quantity
|
||
selectedStock && React.createElement(NumberInput, {
|
||
label: 'Quantity to Allocate',
|
||
description: `Maximum available: ${formatQty(selectedStock.available_quantity)}`,
|
||
value: quantity,
|
||
onChange: setQuantity,
|
||
min: 0.00001,
|
||
max: selectedStock.available_quantity,
|
||
step: 1,
|
||
required: true,
|
||
withAsterisk: true,
|
||
}),
|
||
|
||
// Error
|
||
error && React.createElement(Alert, { color: 'red', variant: 'light', withCloseButton: true, onClose: () => setError(null) }, error),
|
||
|
||
// Buttons
|
||
React.createElement(Group, { justify: 'flex-end', mt: 'md' },
|
||
React.createElement(Button, { variant: 'default', onClick: onCancel }, 'Cancel'),
|
||
React.createElement(Button, {
|
||
onClick: handleSubmit,
|
||
loading: loading,
|
||
disabled: !selectedLine || !selectedStock || !quantity,
|
||
}, 'Add Repair Part')
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// Export the render function for InvenTree plugin system
|
||
// Using the new single-argument signature - InvenTree handles context wrapping
|
||
export function renderRepairPartsPanel(ctx) {
|
||
return window.React.createElement(RepairPartsPanel, { ctx: ctx });
|
||
}
|