Improve frontend panel de control
This commit is contained in:
@@ -94,14 +94,15 @@ function translateKey(
|
|||||||
if (key.startsWith('reasoning.')) {
|
if (key.startsWith('reasoning.')) {
|
||||||
// Remove 'reasoning.' prefix and use reasoning namespace
|
// Remove 'reasoning.' prefix and use reasoning namespace
|
||||||
const translationKey = key.substring('reasoning.'.length);
|
const translationKey = key.substring('reasoning.'.length);
|
||||||
return tReasoning(translationKey, { ...processedParams, defaultValue: key });
|
// Use i18next-icu for interpolation with {variable} syntax
|
||||||
|
return String(tReasoning(translationKey, processedParams));
|
||||||
} else if (key.startsWith('action_queue.') || key.startsWith('dashboard.') || key.startsWith('health.')) {
|
} else if (key.startsWith('action_queue.') || key.startsWith('dashboard.') || key.startsWith('health.')) {
|
||||||
// Use dashboard namespace
|
// Use dashboard namespace
|
||||||
return tDashboard(key, { ...processedParams, defaultValue: key });
|
return String(tDashboard(key, processedParams));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to dashboard
|
// Default to dashboard
|
||||||
return tDashboard(key, { ...processedParams, defaultValue: key });
|
return String(tDashboard(key, processedParams));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionItemCard({
|
function ActionItemCard({
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ function TimelineItemCard({
|
|||||||
translationKey = key.substring('production.'.length);
|
translationKey = key.substring('production.'.length);
|
||||||
namespace = 'production';
|
namespace = 'production';
|
||||||
}
|
}
|
||||||
return { reasoning: t(translationKey, { ...params, ns: namespace, defaultValue: item.reasoning || '' }) };
|
// Use i18next-icu for interpolation with {variable} syntax
|
||||||
|
const fullKey = `${namespace}:${translationKey}`;
|
||||||
|
const result = t(fullKey, params);
|
||||||
|
return { reasoning: String(result || item.reasoning || '') };
|
||||||
} else if (item.reasoning_data) {
|
} else if (item.reasoning_data) {
|
||||||
return formatBatchAction(item.reasoning_data);
|
return formatBatchAction(item.reasoning_data);
|
||||||
}
|
}
|
||||||
@@ -112,11 +115,15 @@ function TimelineItemCard({
|
|||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
{item.status_i18n
|
{item.status_i18n
|
||||||
? t(item.status_i18n.key, {
|
? (() => {
|
||||||
...item.status_i18n.params,
|
const statusKey = item.status_i18n.key;
|
||||||
ns: item.status_i18n.key.startsWith('production.') ? 'production' : 'reasoning',
|
const statusNamespace = statusKey.startsWith('production.') ? 'production' : 'reasoning';
|
||||||
defaultValue: item.statusText || 'Status'
|
const statusTranslationKey = statusKey.startsWith('production.')
|
||||||
})
|
? statusKey.substring('production.'.length)
|
||||||
|
: (statusKey.startsWith('reasoning.') ? statusKey.substring('reasoning.'.length) : statusKey);
|
||||||
|
const fullStatusKey = `${statusNamespace}:${statusTranslationKey}`;
|
||||||
|
return String(t(fullStatusKey, item.status_i18n.params) || item.statusText || 'Status');
|
||||||
|
})()
|
||||||
: item.statusText || 'Status'}
|
: item.statusText || 'Status'}
|
||||||
</span>
|
</span>
|
||||||
{item.status === 'IN_PROGRESS' && (
|
{item.status === 'IN_PROGRESS' && (
|
||||||
|
|||||||
@@ -35,12 +35,19 @@ export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
|
|||||||
const [showRawJson, setShowRawJson] = useState(false);
|
const [showRawJson, setShowRawJson] = useState(false);
|
||||||
const [rawJson, setRawJson] = useState('');
|
const [rawJson, setRawJson] = useState('');
|
||||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
// Initialize pairs from value
|
// Initialize pairs from value only on first mount or when value changes from external source
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if already initialized with internal changes
|
||||||
|
if (isInitialized && pairs.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setPairs([]);
|
setPairs([]);
|
||||||
setRawJson('{}');
|
setRawJson('{}');
|
||||||
|
setIsInitialized(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +58,7 @@ export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
|
|||||||
if (value.trim() === '') {
|
if (value.trim() === '') {
|
||||||
setPairs([]);
|
setPairs([]);
|
||||||
setRawJson('{}');
|
setRawJson('{}');
|
||||||
|
setIsInitialized(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
jsonObj = JSON.parse(value);
|
jsonObj = JSON.parse(value);
|
||||||
@@ -58,23 +66,28 @@ export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
|
|||||||
jsonObj = value;
|
jsonObj = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPairs: KeyValuePair[] = Object.entries(jsonObj).map(([key, val], index) => ({
|
// Only update if there's actual data
|
||||||
id: `pair-${Date.now()}-${index}`,
|
if (Object.keys(jsonObj).length > 0) {
|
||||||
key,
|
const newPairs: KeyValuePair[] = Object.entries(jsonObj).map(([key, val], index) => ({
|
||||||
value: String(val),
|
id: `pair-${Date.now()}-${index}`,
|
||||||
type: detectType(val)
|
key,
|
||||||
}));
|
value: String(val),
|
||||||
|
type: detectType(val)
|
||||||
|
}));
|
||||||
|
|
||||||
setPairs(newPairs);
|
setPairs(newPairs);
|
||||||
setRawJson(JSON.stringify(jsonObj, null, 2));
|
setRawJson(JSON.stringify(jsonObj, null, 2));
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If parsing fails, treat as empty
|
// If parsing fails, treat as empty
|
||||||
setPairs([]);
|
setPairs([]);
|
||||||
setRawJson(typeof value === 'string' ? value : '{}');
|
setRawJson(typeof value === 'string' ? value : '{}');
|
||||||
setJsonError('Invalid JSON');
|
setJsonError('Invalid JSON');
|
||||||
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value, isInitialized, pairs.length]);
|
||||||
|
|
||||||
const detectType = (value: any): 'string' | 'number' | 'boolean' => {
|
const detectType = (value: any): 'string' | 'number' | 'boolean' => {
|
||||||
if (typeof value === 'boolean') return 'boolean';
|
if (typeof value === 'boolean') return 'boolean';
|
||||||
@@ -112,7 +125,8 @@ export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
|
|||||||
};
|
};
|
||||||
const newPairs = [...pairs, newPair];
|
const newPairs = [...pairs, newPair];
|
||||||
setPairs(newPairs);
|
setPairs(newPairs);
|
||||||
onChange?.(pairsToJson(newPairs));
|
// Don't call onChange yet - wait for user to fill in the key
|
||||||
|
// onChange will be called when they type in the key/value
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemovePair = (id: string) => {
|
const handleRemovePair = (id: string) => {
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"purchaseOrder": {
|
"purchaseOrder": {
|
||||||
"low_stock_detection": "Low stock for {{supplier_name}}. Current stock of {{product_names_joined}} will run out in {{days_until_stockout}} days.",
|
"low_stock_detection": "Low stock for {supplier_name}. Current stock of {product_names_joined} will run out in {days_until_stockout} days.",
|
||||||
"forecast_demand": "Order scheduled based on {{forecast_period_days}}-day demand forecast for {{product_names_joined}} from {{supplier_name}}.",
|
"forecast_demand": "Order scheduled based on {forecast_period_days}-day demand forecast for {product_names_joined} from {supplier_name}.",
|
||||||
"safety_stock_replenishment": "Replenishing safety stock for {{product_names_joined}} from {{supplier_name}}.",
|
"safety_stock_replenishment": "Replenishing safety stock for {product_names_joined} from {supplier_name}.",
|
||||||
"supplier_contract": "Scheduled order per contract with {{supplier_name}}.",
|
"supplier_contract": "Scheduled order per contract with {supplier_name}.",
|
||||||
"seasonal_demand": "Seasonal demand preparation for {{product_names_joined}} from {{supplier_name}}.",
|
"seasonal_demand": "Seasonal demand preparation for {product_names_joined} from {supplier_name}.",
|
||||||
"production_requirement": "Required for upcoming production batches from {{supplier_name}}.",
|
"production_requirement": "Required for upcoming production batches from {supplier_name}.",
|
||||||
"manual_request": "Manual purchase request for {{product_names_joined}} from {{supplier_name}}."
|
"manual_request": "Manual purchase request for {product_names_joined} from {supplier_name}."
|
||||||
},
|
},
|
||||||
"productionBatch": {
|
"productionBatch": {
|
||||||
"forecast_demand": "Scheduled based on forecast: {{predicted_demand}} {{product_name}} needed (current stock: {{current_stock}}). Confidence: {{confidence_score}}%.",
|
"forecast_demand": "Scheduled based on forecast: {predicted_demand} {product_name} needed (current stock: {current_stock}). Confidence: {confidence_score}%.",
|
||||||
"customer_order": "Customer order for {{customer_name}}: {{order_quantity}} {{product_name}} (Order #{{order_number}}) - delivery {{delivery_date}}.",
|
"customer_order": "Customer order for {customer_name}: {order_quantity} {product_name} (Order #{order_number}) - delivery {delivery_date}.",
|
||||||
"stock_replenishment": "Stock replenishment for {{product_name}} - current level below minimum.",
|
"stock_replenishment": "Stock replenishment for {product_name} - current level below minimum.",
|
||||||
"seasonal_preparation": "Seasonal preparation batch for {{product_name}}.",
|
"seasonal_preparation": "Seasonal preparation batch for {product_name}.",
|
||||||
"promotion_event": "Production for promotional event - {{product_name}}.",
|
"promotion_event": "Production for promotional event - {product_name}.",
|
||||||
"urgent_order": "Urgent order requiring immediate production of {{product_name}}.",
|
"urgent_order": "Urgent order requiring immediate production of {product_name}.",
|
||||||
"regular_schedule": "Regular scheduled production of {{product_name}}."
|
"regular_schedule": "Regular scheduled production of {product_name}."
|
||||||
},
|
},
|
||||||
"consequence": {
|
"consequence": {
|
||||||
"stockout_risk": "Stock-out risk in {{impact_days}} days. Products affected: {{affected_products_joined}}.",
|
"stockout_risk": "Stock-out risk in {{impact_days}} days. Products affected: {{affected_products_joined}}.",
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"purchaseOrder": {
|
"purchaseOrder": {
|
||||||
"low_stock_detection": "Stock bajo para {{supplier_name}}. El stock actual de {{product_names_joined}} se agotará en {{days_until_stockout}} días.",
|
"low_stock_detection": "Stock bajo para {supplier_name}. El stock actual de {product_names_joined} se agotará en {days_until_stockout} días.",
|
||||||
"forecast_demand": "Pedido programado basado en pronóstico de demanda de {{forecast_period_days}} días para {{product_names_joined}} de {{supplier_name}}.",
|
"forecast_demand": "Pedido programado basado en pronóstico de demanda de {forecast_period_days} días para {product_names_joined} de {supplier_name}.",
|
||||||
"safety_stock_replenishment": "Reposición de stock de seguridad para {{product_names_joined}} de {{supplier_name}}.",
|
"safety_stock_replenishment": "Reposición de stock de seguridad para {product_names_joined} de {supplier_name}.",
|
||||||
"supplier_contract": "Pedido programado según contrato con {{supplier_name}}.",
|
"supplier_contract": "Pedido programado según contrato con {supplier_name}.",
|
||||||
"seasonal_demand": "Preparación de demanda estacional para {{product_names_joined}} de {{supplier_name}}.",
|
"seasonal_demand": "Preparación de demanda estacional para {product_names_joined} de {supplier_name}.",
|
||||||
"production_requirement": "Requerido para próximos lotes de producción de {{supplier_name}}.",
|
"production_requirement": "Requerido para próximos lotes de producción de {supplier_name}.",
|
||||||
"manual_request": "Solicitud de compra manual para {{product_names_joined}} de {{supplier_name}}."
|
"manual_request": "Solicitud de compra manual para {product_names_joined} de {supplier_name}."
|
||||||
},
|
},
|
||||||
"productionBatch": {
|
"productionBatch": {
|
||||||
"forecast_demand": "Programado según pronóstico: {{predicted_demand}} {{product_name}} necesarios (stock actual: {{current_stock}}). Confianza: {{confidence_score}}%.",
|
"forecast_demand": "Programado según pronóstico: {predicted_demand} {product_name} necesarios (stock actual: {current_stock}). Confianza: {confidence_score}%.",
|
||||||
"customer_order": "Pedido de cliente para {{customer_name}}: {{order_quantity}} {{product_name}} (Pedido #{{order_number}}) - entrega {{delivery_date}}.",
|
"customer_order": "Pedido de cliente para {customer_name}: {order_quantity} {product_name} (Pedido #{order_number}) - entrega {delivery_date}.",
|
||||||
"stock_replenishment": "Reposición de stock para {{product_name}} - nivel actual por debajo del mínimo.",
|
"stock_replenishment": "Reposición de stock para {product_name} - nivel actual por debajo del mínimo.",
|
||||||
"seasonal_preparation": "Lote de preparación estacional para {{product_name}}.",
|
"seasonal_preparation": "Lote de preparación estacional para {product_name}.",
|
||||||
"promotion_event": "Producción para evento promocional - {{product_name}}.",
|
"promotion_event": "Producción para evento promocional - {product_name}.",
|
||||||
"urgent_order": "Pedido urgente que requiere producción inmediata de {{product_name}}.",
|
"urgent_order": "Pedido urgente que requiere producción inmediata de {product_name}.",
|
||||||
"regular_schedule": "Producción programada regular de {{product_name}}."
|
"regular_schedule": "Producción programada regular de {product_name}."
|
||||||
},
|
},
|
||||||
"consequence": {
|
"consequence": {
|
||||||
"stockout_risk": "Riesgo de desabastecimiento en {{impact_days}} días. Productos afectados: {{affected_products_joined}}.",
|
"stockout_risk": "Riesgo de desabastecimiento en {{impact_days}} días. Productos afectados: {{affected_products_joined}}.",
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"purchaseOrder": {
|
"purchaseOrder": {
|
||||||
"low_stock_detection": "{{supplier_name}}-rentzat stock baxua. {{product_names_joined}}-ren egungo stocka {{days_until_stockout}} egunetan amaituko da.",
|
"low_stock_detection": "{supplier_name}-rentzat stock baxua. {product_names_joined}-ren egungo stocka {days_until_stockout} egunetan amaituko da.",
|
||||||
"forecast_demand": "{{supplier_name}}-ren {{product_names_joined}}-rentzat {{forecast_period_days}} eguneko eskaera aurreikuspenean oinarritutako eskaera programatua.",
|
"forecast_demand": "{supplier_name}-ren {product_names_joined}-rentzat {forecast_period_days} eguneko eskaera aurreikuspenean oinarritutako eskaera programatua.",
|
||||||
"safety_stock_replenishment": "{{supplier_name}}-ren {{product_names_joined}}-rentzat segurtasun stockaren birjartzea.",
|
"safety_stock_replenishment": "{supplier_name}-ren {product_names_joined}-rentzat segurtasun stockaren birjartzea.",
|
||||||
"supplier_contract": "{{supplier_name}}-rekin kontratuaren arabera programatutako eskaera.",
|
"supplier_contract": "{supplier_name}-rekin kontratuaren arabera programatutako eskaera.",
|
||||||
"seasonal_demand": "{{supplier_name}}-ren {{product_names_joined}}-rentzat denboraldiko eskaeraren prestaketa.",
|
"seasonal_demand": "{supplier_name}-ren {product_names_joined}-rentzat denboraldiko eskaeraren prestaketa.",
|
||||||
"production_requirement": "{{supplier_name}}-ren hurrengo ekoizpen loteetarako beharrezkoa.",
|
"production_requirement": "{supplier_name}-ren hurrengo ekoizpen loteetarako beharrezkoa.",
|
||||||
"manual_request": "{{supplier_name}}-ren {{product_names_joined}}-rentzat eskuzko erosketa eskaera."
|
"manual_request": "{supplier_name}-ren {product_names_joined}-rentzat eskuzko erosketa eskaera."
|
||||||
},
|
},
|
||||||
"productionBatch": {
|
"productionBatch": {
|
||||||
"forecast_demand": "Aurreikuspenen arabera programatua: {{predicted_demand}} {{product_name}} behar dira (egungo stocka: {{current_stock}}). Konfiantza: {{confidence_score}}%.",
|
"forecast_demand": "Aurreikuspenen arabera programatua: {predicted_demand} {product_name} behar dira (egungo stocka: {current_stock}). Konfiantza: {confidence_score}%.",
|
||||||
"customer_order": "{{customer_name}}-rentzat bezeroaren eskaera: {{order_quantity}} {{product_name}} (Eskaera #{{order_number}}) - entrega {{delivery_date}}.",
|
"customer_order": "{customer_name}-rentzat bezeroaren eskaera: {order_quantity} {product_name} (Eskaera #{order_number}) - entrega {delivery_date}.",
|
||||||
"stock_replenishment": "{{product_name}}-rentzat stockaren birjartzea - egungo maila minimoa baino txikiagoa.",
|
"stock_replenishment": "{product_name}-rentzat stockaren birjartzea - egungo maila minimoa baino txikiagoa.",
|
||||||
"seasonal_preparation": "{{product_name}}-rentzat denboraldiko prestaketa lotea.",
|
"seasonal_preparation": "{product_name}-rentzat denboraldiko prestaketa lotea.",
|
||||||
"promotion_event": "Promozio ekitaldirako ekoizpena - {{product_name}}.",
|
"promotion_event": "Promozio ekitaldirako ekoizpena - {product_name}.",
|
||||||
"urgent_order": "{{product_name}}-ren berehalako ekoizpena behar duen eskaera larria.",
|
"urgent_order": "{product_name}-ren berehalako ekoizpena behar duen eskaera larria.",
|
||||||
"regular_schedule": "{{product_name}}-ren ohiko ekoizpen programatua."
|
"regular_schedule": "{product_name}-ren ohiko ekoizpen programatua."
|
||||||
},
|
},
|
||||||
"consequence": {
|
"consequence": {
|
||||||
"stockout_risk": "Stock amaitzeko arriskua {{impact_days}} egunetan. Produktu kaltetuak: {{affected_products_joined}}.",
|
"stockout_risk": "Stock amaitzeko arriskua {{impact_days}} egunetan. Produktu kaltetuak: {{affected_products_joined}}.",
|
||||||
|
|||||||
@@ -390,15 +390,30 @@ class DashboardService:
|
|||||||
# Calculate urgency based on required delivery date
|
# Calculate urgency based on required delivery date
|
||||||
urgency = self._calculate_po_urgency(po)
|
urgency = self._calculate_po_urgency(po)
|
||||||
|
|
||||||
# Get reasoning_data or create default
|
# Get reasoning_data or create intelligent fallback from PO items
|
||||||
reasoning_data = po.get("reasoning_data") or {
|
reasoning_data = po.get("reasoning_data")
|
||||||
"type": "low_stock_detection",
|
if not reasoning_data:
|
||||||
"parameters": {
|
# Extract product names from PO line items for better UX
|
||||||
"supplier_name": po.get('supplier_name', 'Unknown'),
|
product_names = []
|
||||||
"product_names": ["Items"],
|
items = po.get("items", [])
|
||||||
"days_until_stockout": 7
|
for item in items[:5]: # Limit to first 5 items for readability
|
||||||
|
product_name = item.get("product_name") or item.get("name")
|
||||||
|
if product_name:
|
||||||
|
product_names.append(product_name)
|
||||||
|
|
||||||
|
# If no items or product names found, use generic fallback
|
||||||
|
if not product_names:
|
||||||
|
product_names = ["Items"]
|
||||||
|
|
||||||
|
# Create fallback reasoning_data
|
||||||
|
reasoning_data = {
|
||||||
|
"type": "low_stock_detection",
|
||||||
|
"parameters": {
|
||||||
|
"supplier_name": po.get('supplier_name', 'Unknown'),
|
||||||
|
"product_names": product_names,
|
||||||
|
"days_until_stockout": 7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# Get reasoning type and convert to i18n key
|
# Get reasoning type and convert to i18n key
|
||||||
reasoning_type = reasoning_data.get('type', 'inventory_replenishment')
|
reasoning_type = reasoning_data.get('type', 'inventory_replenishment')
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ class PurchaseOrderResponse(PurchaseOrderBase):
|
|||||||
# Additional information
|
# Additional information
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
# AI/ML reasoning for procurement decisions (JTBD dashboard support)
|
||||||
|
reasoning_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
# Audit fields
|
# Audit fields
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ async def create_purchase_order(
|
|||||||
try:
|
try:
|
||||||
# Get product names from items_data
|
# Get product names from items_data
|
||||||
items_list = items_data or []
|
items_list = items_data or []
|
||||||
product_names = [item.get('product_name', f"Product {i+1}") for i, item in enumerate(items_list)]
|
# CRITICAL FIX: Use 'name' key, not 'product_name', to match items_data structure
|
||||||
|
product_names = [item.get('name', item.get('product_name', f"Product {i+1}")) for i, item in enumerate(items_list)]
|
||||||
if not product_names:
|
if not product_names:
|
||||||
product_names = ["Demo Product"]
|
product_names = ["Demo Product"]
|
||||||
|
|
||||||
@@ -192,8 +193,10 @@ async def create_purchase_order(
|
|||||||
if reasoning_data:
|
if reasoning_data:
|
||||||
try:
|
try:
|
||||||
po.reasoning_data = reasoning_data
|
po.reasoning_data = reasoning_data
|
||||||
except Exception:
|
logger.debug(f"Set reasoning_data for PO {po_number}: {reasoning_data.get('type', 'unknown')}")
|
||||||
pass # Columns don't exist yet
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to set reasoning_data for PO {po_number}: {e}")
|
||||||
|
pass # Column might not exist yet
|
||||||
|
|
||||||
# Set approval data if approved
|
# Set approval data if approved
|
||||||
if status in [PurchaseOrderStatus.approved, PurchaseOrderStatus.sent_to_supplier,
|
if status in [PurchaseOrderStatus.approved, PurchaseOrderStatus.sent_to_supplier,
|
||||||
|
|||||||
Reference in New Issue
Block a user