Commit c098b53f498429e04e5871cfb005f7ae2cdb96a0

Authored by zichun
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.
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&lt;MessageKey, string&gt; = {
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 &#39;react&#39;
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   - &times;
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 }
... ...