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,6 +142,23 @@ export const en = {
142 'label.enabled': 'Enabled', 142 'label.enabled': 'Enabled',
143 'label.conditionLogic': 'Logic', 143 'label.conditionLogic': 'Logic',
144 'action.newRule': 'New Rule', 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 } as const 162 } as const
146 163
147 export const zhCN: Record<MessageKey, string> = { 164 export const zhCN: Record<MessageKey, string> = {
@@ -275,6 +292,23 @@ export const zhCN: Record&lt;MessageKey, string&gt; = { @@ -275,6 +292,23 @@ export const zhCN: Record&lt;MessageKey, string&gt; = {
275 'label.enabled': '启用', 292 'label.enabled': '启用',
276 'label.conditionLogic': '逻辑', 293 'label.conditionLogic': '逻辑',
277 'action.newRule': '新建规则', 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 export const locales = { 314 export const locales = {
web/src/pages/CreateSalesOrderPage.tsx
@@ -2,9 +2,9 @@ import { useEffect, useState, type FormEvent } from &#39;react&#39; @@ -2,9 +2,9 @@ import { useEffect, useState, type FormEvent } from &#39;react&#39;
2 import { useNavigate } from 'react-router-dom' 2 import { useNavigate } from 'react-router-dom'
3 import { catalog, partners, salesOrders } from '@/api/client' 3 import { catalog, partners, salesOrders } from '@/api/client'
4 import type { Item, Partner } from '@/types/api' 4 import type { Item, Partner } from '@/types/api'
5 -import { PageHeader } from '@/components/PageHeader'  
6 import { ErrorBox } from '@/components/ErrorBox' 5 import { ErrorBox } from '@/components/ErrorBox'
7 import { DynamicExtFields } from '@/components/DynamicExtFields' 6 import { DynamicExtFields } from '@/components/DynamicExtFields'
  7 +import { useT } from '@/i18n/LocaleContext'
8 8
9 interface LineInput { 9 interface LineInput {
10 itemCode: string 10 itemCode: string
@@ -14,6 +14,7 @@ interface LineInput { @@ -14,6 +14,7 @@ interface LineInput {
14 14
15 export function CreateSalesOrderPage() { 15 export function CreateSalesOrderPage() {
16 const navigate = useNavigate() 16 const navigate = useNavigate()
  17 + const t = useT()
17 const [code, setCode] = useState('') 18 const [code, setCode] = useState('')
18 const [partnerCode, setPartnerCode] = useState('') 19 const [partnerCode, setPartnerCode] = useState('')
19 const [currencyCode] = useState('USD') 20 const [currencyCode] = useState('USD')
@@ -49,6 +50,11 @@ export function CreateSalesOrderPage() { @@ -49,6 +50,11 @@ export function CreateSalesOrderPage() {
49 setLines(next) 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 const onSubmit = async (e: FormEvent) => { 58 const onSubmit = async (e: FormEvent) => {
53 e.preventDefault() 59 e.preventDefault()
54 setError(null) 60 setError(null)
@@ -77,127 +83,250 @@ export function CreateSalesOrderPage() { @@ -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 return ( 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 </div> 193 </div>
128 </div> 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 </div> 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 <DynamicExtFields 317 <DynamicExtFields
185 entityName="SalesOrder" 318 entityName="SalesOrder"
186 values={ext} 319 values={ext}
187 onChange={(k, v) => setExt((prev) => ({ ...prev, [k]: v }))} 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 }