Commit c098b53f498429e04e5871cfb005f7ae2cdb96a0
1 parent
6354c9d3
feat(web): ERP-style form layout prototype (Sales Order)
Replace the card-based create form with a traditional Chinese ERP layout: dense 4-column label/value header grid, top toolbar, tabbed section, and spreadsheet-style inline-editable line items with running totals. All labels go through the i18n system (16 new message keys in en/zh-CN). This is the prototype; same pattern will be applied to all entity forms.
Showing
2 changed files
with
274 additions
and
111 deletions
web/src/i18n/messages.ts
| ... | ... | @@ -142,6 +142,23 @@ export const en = { |
| 142 | 142 | 'label.enabled': 'Enabled', |
| 143 | 143 | 'label.conditionLogic': 'Logic', |
| 144 | 144 | 'action.newRule': 'New Rule', |
| 145 | + | |
| 146 | + // ─── ERP form ───────────────────────────────────────────── | |
| 147 | + 'page.salesOrder.create.title': 'New Sales Order', | |
| 148 | + 'label.orderCode': 'Order Code', | |
| 149 | + 'label.orderDate': 'Order Date', | |
| 150 | + 'label.customer': 'Customer', | |
| 151 | + 'label.createdBy': 'Created By', | |
| 152 | + 'label.createdAt': 'Created At', | |
| 153 | + 'label.autoGenerated': 'Auto-generated', | |
| 154 | + 'tab.orderLines': 'Order Lines', | |
| 155 | + 'label.item': 'Item', | |
| 156 | + 'label.lineTotal': 'Line Total', | |
| 157 | + 'label.selectItem': 'Select item...', | |
| 158 | + 'label.orderTotal': 'Order Total', | |
| 159 | + 'label.customFields': 'Custom Fields', | |
| 160 | + 'action.addRow': 'Add Row', | |
| 161 | + 'action.removeRow': 'Remove', | |
| 145 | 162 | } as const |
| 146 | 163 | |
| 147 | 164 | export const zhCN: Record<MessageKey, string> = { |
| ... | ... | @@ -275,6 +292,23 @@ export const zhCN: Record<MessageKey, string> = { |
| 275 | 292 | 'label.enabled': '启用', |
| 276 | 293 | 'label.conditionLogic': '逻辑', |
| 277 | 294 | 'action.newRule': '新建规则', |
| 295 | + | |
| 296 | + // ─── ERP 表单 ───────────────────────────────────────────── | |
| 297 | + 'page.salesOrder.create.title': '新建销售订单', | |
| 298 | + 'label.orderCode': '订单编码', | |
| 299 | + 'label.orderDate': '订单日期', | |
| 300 | + 'label.customer': '客户', | |
| 301 | + 'label.createdBy': '创建人', | |
| 302 | + 'label.createdAt': '创建时间', | |
| 303 | + 'label.autoGenerated': '自动生成', | |
| 304 | + 'tab.orderLines': '订单明细', | |
| 305 | + 'label.item': '物料', | |
| 306 | + 'label.lineTotal': '行合计', | |
| 307 | + 'label.selectItem': '请选择物料...', | |
| 308 | + 'label.orderTotal': '订单合计', | |
| 309 | + 'label.customFields': '自定义字段', | |
| 310 | + 'action.addRow': '添加行', | |
| 311 | + 'action.removeRow': '删除', | |
| 278 | 312 | } |
| 279 | 313 | |
| 280 | 314 | export const locales = { | ... | ... |
web/src/pages/CreateSalesOrderPage.tsx
| ... | ... | @@ -2,9 +2,9 @@ import { useEffect, useState, type FormEvent } from 'react' |
| 2 | 2 | import { useNavigate } from 'react-router-dom' |
| 3 | 3 | import { catalog, partners, salesOrders } from '@/api/client' |
| 4 | 4 | import type { Item, Partner } from '@/types/api' |
| 5 | -import { PageHeader } from '@/components/PageHeader' | |
| 6 | 5 | import { ErrorBox } from '@/components/ErrorBox' |
| 7 | 6 | import { DynamicExtFields } from '@/components/DynamicExtFields' |
| 7 | +import { useT } from '@/i18n/LocaleContext' | |
| 8 | 8 | |
| 9 | 9 | interface LineInput { |
| 10 | 10 | itemCode: string |
| ... | ... | @@ -14,6 +14,7 @@ interface LineInput { |
| 14 | 14 | |
| 15 | 15 | export function CreateSalesOrderPage() { |
| 16 | 16 | const navigate = useNavigate() |
| 17 | + const t = useT() | |
| 17 | 18 | const [code, setCode] = useState('') |
| 18 | 19 | const [partnerCode, setPartnerCode] = useState('') |
| 19 | 20 | const [currencyCode] = useState('USD') |
| ... | ... | @@ -49,6 +50,11 @@ export function CreateSalesOrderPage() { |
| 49 | 50 | setLines(next) |
| 50 | 51 | } |
| 51 | 52 | |
| 53 | + const orderTotal = lines.reduce( | |
| 54 | + (sum, l) => sum + Number(l.quantity || 0) * Number(l.unitPrice || 0), | |
| 55 | + 0, | |
| 56 | + ) | |
| 57 | + | |
| 52 | 58 | const onSubmit = async (e: FormEvent) => { |
| 53 | 59 | e.preventDefault() |
| 54 | 60 | setError(null) |
| ... | ... | @@ -77,127 +83,250 @@ export function CreateSalesOrderPage() { |
| 77 | 83 | } |
| 78 | 84 | } |
| 79 | 85 | |
| 86 | + /* ── label cell (light blue bg, right-aligned) ── */ | |
| 87 | + const labelCls = | |
| 88 | + 'bg-sky-50 border-b border-r border-slate-200 px-3 py-2 text-right text-slate-600 whitespace-nowrap text-sm' | |
| 89 | + /* ── value cell ── */ | |
| 90 | + const valueCls = 'border-b border-slate-200 px-2 py-1' | |
| 91 | + const valueClsR = `${valueCls} border-r` // value cell with right border (cols 1 & 3) | |
| 92 | + /* ── inline input ── */ | |
| 93 | + const inputCls = 'w-full border-0 outline-none text-sm py-0.5 bg-transparent' | |
| 94 | + | |
| 80 | 95 | return ( |
| 81 | - <div> | |
| 82 | - <PageHeader | |
| 83 | - title="New Sales Order" | |
| 84 | - subtitle="Create a sales order. Confirming it will auto-generate production work orders." | |
| 85 | - actions={ | |
| 86 | - <button className="btn-secondary" onClick={() => navigate('/sales-orders')}> | |
| 87 | - Cancel | |
| 88 | - </button> | |
| 89 | - } | |
| 90 | - /> | |
| 91 | - <form onSubmit={onSubmit} className="card p-6 space-y-5 max-w-3xl"> | |
| 92 | - <div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> | |
| 93 | - <div> | |
| 94 | - <label className="block text-sm font-medium text-slate-700">Order code</label> | |
| 95 | - <input | |
| 96 | - type="text" | |
| 97 | - required | |
| 98 | - value={code} | |
| 99 | - onChange={(e) => setCode(e.target.value)} | |
| 100 | - placeholder="SO-2026-0003" | |
| 101 | - className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500" | |
| 102 | - /> | |
| 103 | - </div> | |
| 104 | - <div> | |
| 105 | - <label className="block text-sm font-medium text-slate-700">Customer</label> | |
| 106 | - <select | |
| 107 | - required | |
| 108 | - value={partnerCode} | |
| 109 | - onChange={(e) => setPartnerCode(e.target.value)} | |
| 110 | - className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-brand-500" | |
| 111 | - > | |
| 112 | - {partnerList.map((p) => ( | |
| 113 | - <option key={p.id} value={p.code}> | |
| 114 | - {p.code} — {p.name} | |
| 115 | - </option> | |
| 116 | - ))} | |
| 117 | - </select> | |
| 118 | - </div> | |
| 119 | - <div> | |
| 120 | - <label className="block text-sm font-medium text-slate-700">Currency</label> | |
| 121 | - <input | |
| 122 | - type="text" | |
| 123 | - value={currencyCode} | |
| 124 | - disabled | |
| 125 | - className="mt-1 w-full rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm" | |
| 126 | - /> | |
| 96 | + <form onSubmit={onSubmit} className="flex flex-col h-full"> | |
| 97 | + {/* ════════ Title bar ════════ */} | |
| 98 | + <div className="flex items-center justify-between bg-white border-b border-slate-200 px-4 py-2"> | |
| 99 | + <h1 className="text-base font-semibold text-slate-800"> | |
| 100 | + {t('page.salesOrder.create.title')} | |
| 101 | + </h1> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + {/* ════════ Top toolbar ════════ */} | |
| 105 | + <div className="flex items-center gap-1 bg-slate-100 border-b border-slate-300 px-3 py-1.5 text-sm"> | |
| 106 | + <button | |
| 107 | + type="submit" | |
| 108 | + className="flex items-center gap-1 px-3 py-1 rounded hover:bg-slate-200 disabled:opacity-50" | |
| 109 | + disabled={submitting} | |
| 110 | + > | |
| 111 | + {submitting ? t('action.creating') : t('action.save')} | |
| 112 | + </button> | |
| 113 | + <div className="w-px h-5 bg-slate-300 mx-1" /> | |
| 114 | + <button | |
| 115 | + type="button" | |
| 116 | + className="flex items-center gap-1 px-3 py-1 rounded hover:bg-slate-200" | |
| 117 | + onClick={() => navigate('/sales-orders')} | |
| 118 | + > | |
| 119 | + {t('action.cancel')} | |
| 120 | + </button> | |
| 121 | + </div> | |
| 122 | + | |
| 123 | + {/* ════════ Scrollable body ════════ */} | |
| 124 | + <div className="flex-1 overflow-y-auto p-4 bg-slate-50 space-y-4"> | |
| 125 | + | |
| 126 | + {/* ════════ Form header — dense grid ════════ */} | |
| 127 | + <div className="border border-slate-300 bg-white"> | |
| 128 | + <div className="grid grid-cols-4 text-sm"> | |
| 129 | + {/* Row 1 */} | |
| 130 | + <div className={labelCls}> | |
| 131 | + <span className="text-rose-500">*</span> {t('label.orderCode')} | |
| 132 | + </div> | |
| 133 | + <div className={valueClsR}> | |
| 134 | + <input | |
| 135 | + type="text" | |
| 136 | + required | |
| 137 | + value={code} | |
| 138 | + onChange={(e) => setCode(e.target.value)} | |
| 139 | + placeholder="SO-2026-0003" | |
| 140 | + className={inputCls} | |
| 141 | + /> | |
| 142 | + </div> | |
| 143 | + <div className={labelCls}> | |
| 144 | + {t('label.orderDate')} | |
| 145 | + </div> | |
| 146 | + <div className={valueCls}> | |
| 147 | + <input | |
| 148 | + type="text" | |
| 149 | + value={new Date().toISOString().slice(0, 10)} | |
| 150 | + disabled | |
| 151 | + className={`${inputCls} text-slate-500`} | |
| 152 | + /> | |
| 153 | + </div> | |
| 154 | + | |
| 155 | + {/* Row 2 */} | |
| 156 | + <div className={labelCls}> | |
| 157 | + <span className="text-rose-500">*</span> {t('label.customer')} | |
| 158 | + </div> | |
| 159 | + <div className={valueClsR}> | |
| 160 | + <select | |
| 161 | + required | |
| 162 | + value={partnerCode} | |
| 163 | + onChange={(e) => setPartnerCode(e.target.value)} | |
| 164 | + className={`${inputCls} cursor-pointer`} | |
| 165 | + > | |
| 166 | + {partnerList.map((p) => ( | |
| 167 | + <option key={p.id} value={p.code}> | |
| 168 | + {p.code} — {p.name} | |
| 169 | + </option> | |
| 170 | + ))} | |
| 171 | + </select> | |
| 172 | + </div> | |
| 173 | + <div className={labelCls}> | |
| 174 | + {t('label.currency')} | |
| 175 | + </div> | |
| 176 | + <div className={valueCls}> | |
| 177 | + <span className="text-sm text-slate-500">{currencyCode}</span> | |
| 178 | + </div> | |
| 179 | + | |
| 180 | + {/* Row 3 — system / auto fields */} | |
| 181 | + <div className={labelCls}> | |
| 182 | + {t('label.createdBy')} | |
| 183 | + </div> | |
| 184 | + <div className={valueClsR}> | |
| 185 | + <span className="text-sm text-slate-400">{t('label.autoGenerated')}</span> | |
| 186 | + </div> | |
| 187 | + <div className={labelCls}> | |
| 188 | + {t('label.createdAt')} | |
| 189 | + </div> | |
| 190 | + <div className={valueCls}> | |
| 191 | + <span className="text-sm text-slate-400">{t('label.autoGenerated')}</span> | |
| 192 | + </div> | |
| 127 | 193 | </div> |
| 128 | 194 | </div> |
| 129 | 195 | |
| 130 | - <div> | |
| 131 | - <div className="flex items-center justify-between mb-2"> | |
| 132 | - <label className="text-sm font-medium text-slate-700">Order lines</label> | |
| 133 | - <button type="button" className="btn-secondary text-xs" onClick={addLine}> | |
| 134 | - + Add line | |
| 135 | - </button> | |
| 136 | - </div> | |
| 137 | - <div className="space-y-2"> | |
| 138 | - {lines.map((line, idx) => ( | |
| 139 | - <div key={idx} className="flex items-center gap-2"> | |
| 140 | - <span className="w-6 text-xs text-slate-400 text-right">{idx + 1}</span> | |
| 141 | - <select | |
| 142 | - value={line.itemCode} | |
| 143 | - onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} | |
| 144 | - className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" | |
| 145 | - > | |
| 146 | - <option value="">Select item...</option> | |
| 147 | - {items.map((it) => ( | |
| 148 | - <option key={it.id} value={it.code}> | |
| 149 | - {it.code} — {it.name} | |
| 150 | - </option> | |
| 151 | - ))} | |
| 152 | - </select> | |
| 153 | - <input | |
| 154 | - type="number" | |
| 155 | - min="1" | |
| 156 | - step="1" | |
| 157 | - placeholder="Qty" | |
| 158 | - value={line.quantity} | |
| 159 | - onChange={(e) => updateLine(idx, 'quantity', e.target.value)} | |
| 160 | - className="w-20 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" | |
| 161 | - /> | |
| 162 | - <input | |
| 163 | - type="number" | |
| 164 | - min="0" | |
| 165 | - step="0.01" | |
| 166 | - placeholder="Price" | |
| 167 | - value={line.unitPrice} | |
| 168 | - onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} | |
| 169 | - className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-right" | |
| 170 | - /> | |
| 171 | - <button | |
| 172 | - type="button" | |
| 173 | - className="text-slate-400 hover:text-rose-500" | |
| 174 | - onClick={() => removeLine(idx)} | |
| 175 | - title="Remove line" | |
| 176 | - > | |
| 177 | - × | |
| 178 | - </button> | |
| 179 | - </div> | |
| 180 | - ))} | |
| 181 | - </div> | |
| 196 | + {/* ════════ Tabs ════════ */} | |
| 197 | + <div className="border-b border-slate-300"> | |
| 198 | + <button | |
| 199 | + type="button" | |
| 200 | + className="px-4 py-2 text-sm border-b-2 border-blue-500 text-blue-600 font-medium" | |
| 201 | + > | |
| 202 | + {t('tab.orderLines')} | |
| 203 | + </button> | |
| 182 | 204 | </div> |
| 183 | 205 | |
| 206 | + {/* ════════ Line items — spreadsheet table ════════ */} | |
| 207 | + <div className="border border-slate-300 bg-white"> | |
| 208 | + <table className="w-full text-sm"> | |
| 209 | + <thead> | |
| 210 | + <tr className="bg-slate-100 border-b border-slate-300"> | |
| 211 | + <th className="px-2 py-2 text-center w-10 border-r border-slate-200">#</th> | |
| 212 | + <th className="px-2 py-2 text-left border-r border-slate-200"> | |
| 213 | + <span className="text-rose-500">*</span> {t('label.item')} | |
| 214 | + </th> | |
| 215 | + <th className="px-2 py-2 text-right border-r border-slate-200 w-24"> | |
| 216 | + <span className="text-rose-500">*</span> {t('label.quantity')} | |
| 217 | + </th> | |
| 218 | + <th className="px-2 py-2 text-right border-r border-slate-200 w-28"> | |
| 219 | + <span className="text-rose-500">*</span> {t('label.unitPrice')} | |
| 220 | + </th> | |
| 221 | + <th className="px-2 py-2 text-right border-r border-slate-200 w-28"> | |
| 222 | + {t('label.lineTotal')} | |
| 223 | + </th> | |
| 224 | + <th className="px-2 py-2 text-center w-24">{t('label.actions')}</th> | |
| 225 | + </tr> | |
| 226 | + </thead> | |
| 227 | + <tbody> | |
| 228 | + {lines.map((line, idx) => ( | |
| 229 | + <tr key={idx} className="border-b border-slate-200 hover:bg-sky-50"> | |
| 230 | + <td className="px-2 py-1.5 text-center border-r border-slate-200 text-slate-400"> | |
| 231 | + {idx + 1} | |
| 232 | + </td> | |
| 233 | + <td className="px-1 py-0.5 border-r border-slate-200"> | |
| 234 | + <select | |
| 235 | + value={line.itemCode} | |
| 236 | + onChange={(e) => updateLine(idx, 'itemCode', e.target.value)} | |
| 237 | + className="w-full border-0 outline-none text-sm py-1 bg-transparent" | |
| 238 | + > | |
| 239 | + <option value="">{t('label.selectItem')}</option> | |
| 240 | + {items.map((it) => ( | |
| 241 | + <option key={it.id} value={it.code}> | |
| 242 | + {it.code} — {it.name} | |
| 243 | + </option> | |
| 244 | + ))} | |
| 245 | + </select> | |
| 246 | + </td> | |
| 247 | + <td className="px-1 py-0.5 border-r border-slate-200"> | |
| 248 | + <input | |
| 249 | + type="number" | |
| 250 | + min="1" | |
| 251 | + step="1" | |
| 252 | + placeholder="0" | |
| 253 | + value={line.quantity} | |
| 254 | + onChange={(e) => updateLine(idx, 'quantity', e.target.value)} | |
| 255 | + className="w-full border-0 outline-none text-sm py-1 text-right bg-transparent" | |
| 256 | + /> | |
| 257 | + </td> | |
| 258 | + <td className="px-1 py-0.5 border-r border-slate-200"> | |
| 259 | + <input | |
| 260 | + type="number" | |
| 261 | + min="0" | |
| 262 | + step="0.01" | |
| 263 | + placeholder="0.00" | |
| 264 | + value={line.unitPrice} | |
| 265 | + onChange={(e) => updateLine(idx, 'unitPrice', e.target.value)} | |
| 266 | + className="w-full border-0 outline-none text-sm py-1 text-right bg-transparent" | |
| 267 | + /> | |
| 268 | + </td> | |
| 269 | + <td className="px-2 py-1.5 text-right border-r border-slate-200 text-slate-500"> | |
| 270 | + {(Number(line.quantity || 0) * Number(line.unitPrice || 0)).toFixed(2)} | |
| 271 | + </td> | |
| 272 | + <td className="px-2 py-1.5 text-center"> | |
| 273 | + <button | |
| 274 | + type="button" | |
| 275 | + onClick={addLine} | |
| 276 | + title={t('action.addRow')} | |
| 277 | + className="text-blue-500 hover:text-blue-700 mx-0.5 text-xs" | |
| 278 | + > | |
| 279 | + [+] | |
| 280 | + </button> | |
| 281 | + <button | |
| 282 | + type="button" | |
| 283 | + onClick={() => removeLine(idx)} | |
| 284 | + title={t('action.removeRow')} | |
| 285 | + className="text-rose-400 hover:text-rose-600 mx-0.5 text-xs" | |
| 286 | + > | |
| 287 | + [x] | |
| 288 | + </button> | |
| 289 | + </td> | |
| 290 | + </tr> | |
| 291 | + ))} | |
| 292 | + </tbody> | |
| 293 | + <tfoot> | |
| 294 | + <tr className="bg-slate-50 border-t border-slate-300"> | |
| 295 | + <td colSpan={2} className="px-2 py-1.5 text-right"> | |
| 296 | + <button | |
| 297 | + type="button" | |
| 298 | + onClick={addLine} | |
| 299 | + className="text-blue-600 hover:text-blue-800 text-xs font-medium" | |
| 300 | + > | |
| 301 | + {t('action.addLine')} | |
| 302 | + </button> | |
| 303 | + </td> | |
| 304 | + <td colSpan={2} className="px-2 py-1.5 text-right font-medium text-slate-700"> | |
| 305 | + {t('label.orderTotal')} | |
| 306 | + </td> | |
| 307 | + <td className="px-2 py-1.5 text-right font-semibold text-slate-800 border-r border-slate-200"> | |
| 308 | + {orderTotal.toFixed(2)} | |
| 309 | + </td> | |
| 310 | + <td /> | |
| 311 | + </tr> | |
| 312 | + </tfoot> | |
| 313 | + </table> | |
| 314 | + </div> | |
| 315 | + | |
| 316 | + {/* ════════ Custom fields (Tier 1 ext) ════════ */} | |
| 184 | 317 | <DynamicExtFields |
| 185 | 318 | entityName="SalesOrder" |
| 186 | 319 | values={ext} |
| 187 | 320 | onChange={(k, v) => setExt((prev) => ({ ...prev, [k]: v }))} |
| 188 | 321 | /> |
| 189 | 322 | |
| 190 | - {error && <ErrorBox error={error} />} | |
| 191 | - | |
| 192 | - <div className="flex items-center gap-3 pt-2"> | |
| 193 | - <button type="submit" className="btn-primary" disabled={submitting}> | |
| 194 | - {submitting ? 'Creating...' : 'Create Sales Order'} | |
| 195 | - </button> | |
| 196 | - <span className="text-xs text-slate-400"> | |
| 197 | - After creation, confirm the order to auto-generate work orders. | |
| 198 | - </span> | |
| 199 | - </div> | |
| 200 | - </form> | |
| 201 | - </div> | |
| 323 | + {/* ════════ Error ════════ */} | |
| 324 | + {error && ( | |
| 325 | + <div className="max-w-2xl"> | |
| 326 | + <ErrorBox error={error} /> | |
| 327 | + </div> | |
| 328 | + )} | |
| 329 | + </div> | |
| 330 | + </form> | |
| 202 | 331 | ) |
| 203 | 332 | } | ... | ... |