Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ npm install && npm run dev
- `GET /api/dashboard/summary` - All filters
- `GET /api/demand`, `/api/backlog` - No filters
- `GET /api/spending/*` - Summary, monthly, categories, transactions
- `GET /api/restocking/recommendations` - Required: budget. Filters: warehouse, category
- `GET|POST /api/restocking/orders` - No filters. The only write endpoint; persists to `server/data/restock_orders.json`

## Code Conventions

**Always document non-obvious logic with comments.** When logic encodes something the code itself can't show — a business rule, a workaround, an ordering dependency, a magic constant — add a short comment stating *why*, not what. Match the surrounding comment style and density; don't narrate self-evident lines.

```javascript
// Inventory has no time dimension, so the month filter is intentionally ignored here
const params = buildParams({ warehouse, category })
```

## Common Issues
1. Use unique keys in v-for (not `index`) - use `sku`, `month`, etc.
Expand Down
3 changes: 3 additions & 0 deletions client/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
<router-link to="/orders" :class="{ active: $route.path === '/orders' }">
{{ t('nav.orders') }}
</router-link>
<router-link to="/restocking" :class="{ active: $route.path === '/restocking' }">
{{ t('nav.restocking') }}
</router-link>
<router-link to="/spending" :class="{ active: $route.path === '/spending' }">
{{ t('nav.finance') }}
</router-link>
Expand Down
20 changes: 20 additions & 0 deletions client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,25 @@ export const api = {
async getPurchaseOrderByBacklogItem(backlogItemId) {
const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`)
return response.data
},

async getRestockRecommendations(budget, filters = {}) {
const params = new URLSearchParams()
params.append('budget', budget)
if (filters.warehouse && filters.warehouse !== 'all') params.append('warehouse', filters.warehouse)
if (filters.category && filters.category !== 'all') params.append('category', filters.category)

const response = await axios.get(`${API_BASE_URL}/restocking/recommendations?${params.toString()}`)
return response.data
},

async getRestockOrders() {
const response = await axios.get(`${API_BASE_URL}/restocking/orders`)
return response.data
},

async createRestockOrder(orderData) {
const response = await axios.post(`${API_BASE_URL}/restocking/orders`, orderData)
return response.data
}
}
42 changes: 41 additions & 1 deletion client/src/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default {
overview: 'Overview',
inventory: 'Inventory',
orders: 'Orders',
restocking: 'Restocking',
finance: 'Finance',
demandForecast: 'Demand Forecast',
companyName: 'Catalyst Components',
Expand Down Expand Up @@ -112,6 +113,8 @@ export default {
onTimeDelivery: 'On-Time Delivery',
itemsCount: '{count} items',
quantity: 'Qty',
submittedOrders: 'Submitted Restock Orders',
noSubmittedOrders: 'No restock orders submitted yet',
table: {
orderNumber: 'Order Number',
orderId: 'Order ID',
Expand All @@ -125,7 +128,43 @@ export default {
totalValue: 'Total Value',
status: 'Status',
expectedDelivery: 'Expected Delivery',
actualDelivery: 'Actual Delivery'
actualDelivery: 'Actual Delivery',
submittedDate: 'Submitted',
leadTime: 'Lead Time'
}
},

// Restocking
restocking: {
title: 'Restocking',
description: 'Set a budget and order the items that need restocking most',
availableBudget: 'Available Budget',
recommendedItems: 'Recommended Items',
budgetUsed: 'Budget Allocated',
budgetRemaining: 'Budget Remaining',
itemsRecommended: 'Items Recommended',
placeOrder: 'Place Order',
placingOrder: 'Placing order...',
orderPlaced: 'Restock order {orderNumber} submitted',
orderPlacedDetail: 'Expected delivery {date} ({days} day lead time)',
orderFailed: 'Failed to submit restock order',
viewInOrders: 'View in Orders',
allStocked: 'Every item is adequately stocked. Nothing needs restocking.',
budgetTooLow: 'Budget is below the cheapest item ({amount}). Increase the budget to see recommendations.',
days: '{count} days',
critical: 'Critical',
low: 'Low',
partial: 'Partial',
table: {
onHand: 'On Hand',
reorderPoint: 'Reorder Point',
target: 'Target',
shortfall: 'Shortfall',
orderQuantity: 'Order Qty',
unitCost: 'Unit Cost',
lineCost: 'Line Cost',
leadTime: 'Lead Time',
priority: 'Priority'
}
},

Expand Down Expand Up @@ -204,6 +243,7 @@ export default {
shipped: 'Shipped',
processing: 'Processing',
backordered: 'Backordered',
submitted: 'Submitted',
inStock: 'In Stock',
lowStock: 'Low Stock',
adequate: 'Adequate'
Expand Down
42 changes: 41 additions & 1 deletion client/src/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default {
overview: '概要',
inventory: '在庫',
orders: '注文',
restocking: '在庫補充',
finance: '財務',
demandForecast: '需要予測',
companyName: '触媒コンポーネンツ',
Expand Down Expand Up @@ -112,6 +113,8 @@ export default {
onTimeDelivery: '定時配達',
itemsCount: '{count}件',
quantity: '数量',
submittedOrders: '送信済み補充注文',
noSubmittedOrders: '送信済みの補充注文はありません',
table: {
orderNumber: '注文番号',
orderId: '注文ID',
Expand All @@ -125,7 +128,43 @@ export default {
totalValue: '合計金額',
status: 'ステータス',
expectedDelivery: '予定配達日',
actualDelivery: '実際の配達日'
actualDelivery: '実際の配達日',
submittedDate: '送信日',
leadTime: 'リードタイム'
}
},

// Restocking
restocking: {
title: '在庫補充',
description: '予算を設定し、補充が最も必要な品目を発注します',
availableBudget: '利用可能予算',
recommendedItems: '推奨品目',
budgetUsed: '割当予算',
budgetRemaining: '残予算',
itemsRecommended: '推奨品目数',
placeOrder: '発注する',
placingOrder: '発注中...',
orderPlaced: '補充注文 {orderNumber} を送信しました',
orderPlacedDetail: '予定配達日 {date}(リードタイム {days} 日)',
orderFailed: '補充注文の送信に失敗しました',
viewInOrders: '注文タブで確認',
allStocked: 'すべての品目の在庫は十分です。補充は不要です。',
budgetTooLow: '予算が最安値の品目({amount})を下回っています。予算を増やしてください。',
days: '{count} 日',
critical: '緊急',
low: '低',
partial: '一部',
table: {
onHand: '在庫数',
reorderPoint: '発注点',
target: '目標数量',
shortfall: '不足数',
orderQuantity: '発注数量',
unitCost: '単価',
lineCost: '小計',
leadTime: 'リードタイム',
priority: '優先度'
}
},

Expand Down Expand Up @@ -204,6 +243,7 @@ export default {
shipped: '出荷済み',
processing: '処理中',
backordered: 'バックオーダー',
submitted: '送信済み',
inStock: '在庫あり',
lowStock: '在庫僅少',
adequate: '適量'
Expand Down
2 changes: 2 additions & 0 deletions client/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import App from './App.vue'
import Dashboard from './views/Dashboard.vue'
import Inventory from './views/Inventory.vue'
import Orders from './views/Orders.vue'
import Restocking from './views/Restocking.vue'
import Demand from './views/Demand.vue'
import Spending from './views/Spending.vue'
import Reports from './views/Reports.vue'
Expand All @@ -14,6 +15,7 @@ const router = createRouter({
{ path: '/', component: Dashboard },
{ path: '/inventory', component: Inventory },
{ path: '/orders', component: Orders },
{ path: '/restocking', component: Restocking },
{ path: '/demand', component: Demand },
{ path: '/spending', component: Spending },
{ path: '/reports', component: Reports }
Expand Down
112 changes: 110 additions & 2 deletions client/src/views/Orders.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,69 @@
</div>
</div>

<!--
Restock orders submitted from the Restocking tab ignore the global FilterBar
on purpose:
- Time Period only offers 2025 months, but these orders are submitted "now"
- Order Status only offers Delivered/Shipped/Processing/Backordered, and
"Submitted" doesn't match any of them
- A single restock order can span multiple warehouses and categories
Applying any of the four filters would spuriously empty this list. There's
precedent for this: /api/demand and /api/backlog also ignore filters.
-->
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ t('orders.submittedOrders') }} ({{ submittedOrders.length }})</h3>
</div>
<div v-if="submittedError" class="error">{{ submittedError }}</div>
<div v-else-if="submittedOrders.length === 0" class="no-submitted-orders">
{{ t('orders.noSubmittedOrders') }}
</div>
<div v-else class="table-container">
<table class="orders-table">
<thead>
<tr>
<th class="col-order-number">{{ t('orders.table.orderNumber') }}</th>
<th class="col-items">{{ t('orders.table.items') }}</th>
<th class="col-status">{{ t('orders.table.status') }}</th>
<th class="col-date">{{ t('orders.table.submittedDate') }}</th>
<th class="col-lead-time">{{ t('orders.table.leadTime') }}</th>
<th class="col-date">{{ t('orders.table.expectedDelivery') }}</th>
<th class="col-value">{{ t('orders.table.totalValue') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in submittedOrders" :key="order.id">
<td class="col-order-number"><strong>{{ order.order_number }}</strong></td>
<td class="col-items">
<details class="items-details">
<summary class="items-summary">
{{ t('orders.itemsCount', { count: order.items.length }) }}
</summary>
<div class="items-dropdown">
<div v-for="(item, idx) in order.items" :key="idx" class="item-entry">
<span class="item-name">{{ item.name }}</span>
<!-- Unit costs need cents: formatCurrency would round $89.50 to $90 -->
<span class="item-meta">{{ t('orders.quantity') }}: {{ item.quantity }} @ {{ formatCurrencyWithDecimals(item.unit_cost, currentCurrency, 2) }}</span>
</div>
</div>
</details>
</td>
<td class="col-status">
<span :class="['badge', getOrderStatusClass(order.status)]">
{{ t(`status.${order.status.toLowerCase()}`) }}
</span>
</td>
<td class="col-date">{{ formatDate(order.submitted_at) }}</td>
<td class="col-lead-time">{{ t('restocking.days', { count: order.lead_time_days }) }}</td>
<td class="col-date">{{ formatDate(order.expected_delivery) }}</td>
<td class="col-value"><strong>{{ formatCurrency(order.total_value, currentCurrency) }}</strong></td>
</tr>
</tbody>
</table>
</div>
</div>

<div class="card">
<div class="card-header">
<h3 class="card-title">{{ t('orders.allOrders') }} ({{ orders.length }})</h3>
Expand Down Expand Up @@ -83,6 +146,7 @@ import { ref, onMounted, watch, computed } from 'vue'
import { api } from '../api'
import { useFilters } from '../composables/useFilters'
import { useI18n } from '../composables/useI18n'
import { formatCurrency, formatCurrencyWithDecimals } from '../utils/currency'

export default {
name: 'Orders',
Expand All @@ -96,6 +160,13 @@ export default {
const error = ref(null)
const orders = ref([])

// Submitted restock orders (from the Restocking tab) have their own loading
// state and their own error ref, kept separate from the page-level error
// above. If the backend hasn't picked up this endpoint yet, we want just
// this section to show an error, not blank out the whole Orders page.
const submittedOrders = ref([])
const submittedError = ref(null)

// Use shared filters
const {
selectedPeriod,
Expand Down Expand Up @@ -129,6 +200,21 @@ export default {
loadOrders()
})

// Deliberately NOT added to the watch above: restock orders are
// filter-independent (see template comment), so refetching them on every
// filter change would be wasted work.
const loadSubmittedOrders = async () => {
try {
submittedOrders.value = await api.getRestockOrders()
} catch (err) {
// Only set submittedError here, never the page-level `error` ref -
// otherwise a backend that hasn't been restarted yet with this
// endpoint would blank out the entire Orders page instead of just
// this section.
submittedError.value = 'Failed to load submitted restock orders: ' + err.message
}
}

const getOrdersByStatus = (status) => {
return orders.value.filter(order => order.status === status)
}
Expand All @@ -138,7 +224,8 @@ export default {
'Delivered': 'success',
'Shipped': 'info',
'Processing': 'warning',
'Backordered': 'danger'
'Backordered': 'danger',
'Submitted': 'info'
}
return statusMap[status] || 'info'
}
Expand All @@ -153,17 +240,25 @@ export default {
})
}

onMounted(loadOrders)
onMounted(() => {
loadOrders()
loadSubmittedOrders()
})

return {
t,
loading,
error,
orders,
submittedOrders,
submittedError,
getOrdersByStatus,
getOrderStatusClass,
formatDate,
currencySymbol,
currentCurrency,
formatCurrency,
formatCurrencyWithDecimals,
translateProductName,
translateCustomerName
}
Expand Down Expand Up @@ -203,6 +298,19 @@ export default {
width: 120px;
}

.col-lead-time {
width: 110px;
}

/* Empty state for the submitted restock orders card, styled to match .no-tasks in TasksModal.vue */
.no-submitted-orders {
text-align: center;
padding: 3rem;
color: #64748b;
font-size: 1.1rem;
font-style: italic;
}

/* Items details styling */
.items-details {
position: relative;
Expand Down
Loading