quotation-editor.component.html 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. <!-- 基于Product表的报价编辑器组件 -->
  2. <div class="quotation-editor">
  3. <!-- 加载状态 -->
  4. @if (loading) {
  5. <div class="loading-container">
  6. <div class="spinner">
  7. <div class="spinner-circle"></div>
  8. </div>
  9. <p>加载报价数据...</p>
  10. </div>
  11. }
  12. @if (!loading) {
  13. <!-- 产品管理区域 -->
  14. @if (canEdit) {
  15. <div class="product-management">
  16. <div class="product-header">
  17. <h3>产品设计产品 ({{ products.length }}个)</h3>
  18. <div class="product-actions">
  19. <button class="btn-primary" (click)="generateQuotationFromProducts()">
  20. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  21. <path fill="currentColor" d="M447.1 96h-288C131.3 96 112 115.3 112 140.4v231.2C112 396.7 131.3 416 159.1 416h288c27.6 0 48-19.3 48-44.4V140.4C495.1 115.3 475.6 96 447.1 96zM447.1 144v192h-288V144H447.1zM336 320c17.7 0 32-14.3 32-32s-14.3-32-32-32c-11.4 0-21.4 5.9-27.1 14.9c-7.2-2.4-14.9-3.7-22.9-3.7-30.9 0-56 25.1-56 56s25.1 56 56 56c8 0 15.7-1.3 22.9-3.7C314.6 314.1 324.6 320 336 320z"/>
  22. <path fill="currentColor" d="M176 80C176 71.16 167.8 64 160 64H80C71.16 64 64 71.16 64 80s7.163 16 16 16h32L64 192C64 209.7 81.75 224 96 224s32-14.3 32-32L112 96h48C167.8 96 176 88.84 176 80z"/>
  23. </svg>
  24. 生成报价
  25. </button>
  26. <button class="btn-secondary" (click)="addProduct()">
  27. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  28. <path fill="currentColor" d="M256 512c141.4 0 256-114.6 256-256S397.4 0 256 0S0 114.6 0 256S114.6 512 256 512zM256 48c114.7 0 208 93.31 208 208s-93.31 208-208 208S48 370.7 48 256S141.3 48 256 48zM256 336c13.25 0 24-10.75 24-24V280h32c13.25 0 24-10.75 24-24s-10.75-24-24-24h-32V176c0-13.25-10.75-24-24-24s-24 10.75-24 24v32H200c-13.25 0-24 10.75-24 24s10.75 24 24 24h32v32C232 325.3 242.8 336 256 336z"/>
  29. </svg>
  30. 添加产品
  31. </button>
  32. <button class="btn-outline" (click)="saveQuotation()">
  33. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  34. <path fill="currentColor" d="M504.1 141C490.6 121.4 471.1 107.5 447.8 96C424.6 84.51 400.8 80 376.1 80H136c-24.74 0-48.48 4.511-71.79 16.01C40.88 107.5 21.36 121.4 7.85 141C-5.654 160.6-1.466 180.2 11.66 195.7L144.1 353c11.14 13.4 27.62 21 44.8 21h124.3c17.18 0 33.66-7.6 44.8-21l133.3-157.4C504.5 180.2 508.6 160.6 504.1 141zM434.1 165.6L300.7 322.1c-3.734 4.498-9.291 7.059-15.16 7.059H226.5c-5.871 0-11.43-2.561-15.16-7.059L77.86 165.6C72.16 158.7 70.54 149.7 73.65 141.3C76.77 132.9 83.98 126.5 92.95 123.4C107.3 118.7 122.4 116 137.7 116h236.5c15.28 0 30.43 2.687 44.77 7.393c8.972 3.104 16.18 9.516 19.3 17.94C441.5 149.7 439.8 158.7 434.1 165.6z"/>
  35. </svg>
  36. 保存报价
  37. </button>
  38. </div>
  39. </div>
  40. </div>
  41. }
  42. @if (quotation.spaces.length === 0 && products.length > 0) {
  43. <!-- 空状态 - 有产品但未生成报价 -->
  44. <div class="empty-state">
  45. <svg class="icon empty-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  46. <path fill="currentColor" d="M447.1 96h-288C131.3 96 112 115.3 112 140.4v231.2C112 396.7 131.3 416 159.1 416h288c27.6 0 48-19.3 48-44.4V140.4C495.1 115.3 475.6 96 447.1 96zM447.1 144v192h-288V144H447.1zM336 320c17.7 0 32-14.3 32-32s-14.3-32-32-32c-11.4 0-21.4 5.9-27.1 14.9c-7.2-2.4-14.9-3.7-22.9-3.7-30.9 0-56 25.1-56 56s25.1 56 56 56c8 0 15.7-1.3 22.9-3.7C314.6 314.1 324.6 320 336 320z"/>
  47. </svg>
  48. <p class="empty-message">尚未生成报价</p>
  49. <p class="empty-hint">已加载 {{ products.length }} 个产品设计产品,请点击"生成报价"按钮</p>
  50. @if (canEdit) {
  51. <button class="btn-primary" (click)="generateQuotationFromProducts()">立即生成报价</button>
  52. }
  53. </div>
  54. } @else if (quotation.spaces.length === 0) {
  55. <!-- 完全空状态 - 无产品 -->
  56. <div class="empty-state">
  57. <svg class="icon empty-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  58. <path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 464c-114.7 0-208-93.31-208-208S141.3 48 256 48s208 93.31 208 208S370.7 464 256 464zM256 224c-17.67 0-32 14.33-32 32c0 17.67 14.33 32 32 32s32-14.33 32-32C288 238.3 273.7 224 256 224zM320 128H192c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h128c17.67 0 32-14.33 32-32v-32C352 142.3 337.7 128 320 128z"/>
  59. </svg>
  60. <p class="empty-message">暂无产品设计产品</p>
  61. <p class="empty-hint">该项目还没有创建任何产品设计产品</p>
  62. @if (canEdit) {
  63. <button class="btn-primary" (click)="addProduct()">创建第一个产品</button>
  64. }
  65. </div>
  66. } @else {
  67. <!-- 报价工具栏 -->
  68. <div class="quotation-toolbar">
  69. <div class="toolbar-left">
  70. <h4 class="toolbar-title">报价明细 ({{ quotation.spaces.length }}个产品设计产品)</h4>
  71. <div class="toolbar-meta">
  72. @if (quotation.generatedAt) {
  73. <span class="generate-time">生成于: {{ quotation.generatedAt | date:'MM-dd HH:mm' }}</span>
  74. }
  75. @if (quotation.validUntil) {
  76. <span class="valid-until">有效期至: {{ quotation.validUntil | date:'yyyy-MM-dd' }}</span>
  77. }
  78. </div>
  79. </div>
  80. <div class="toolbar-right">
  81. <button class="btn-icon" (click)="expandAll()" title="展开全部">
  82. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  83. <path fill="currentColor" d="M112 184l144 144 144-144M256 328V88"/>
  84. </svg>
  85. </button>
  86. <button class="btn-icon" (click)="collapseAll()" title="折叠全部">
  87. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  88. <path fill="currentColor" d="M112 328l144-144 144 144M256 184v240"/>
  89. </svg>
  90. </button>
  91. </div>
  92. </div>
  93. <!-- 卡片视图 -->
  94. @if (viewMode === 'card') {
  95. <div class="quotation-products">
  96. @for (space of quotation.spaces; track space.name) {
  97. <div class="product-card" [class.expanded]="isProductExpanded(space.name)">
  98. <!-- 产品头部 -->
  99. <div class="product-header" (click)="toggleProductExpand(space.name)">
  100. <div class="product-info">
  101. <div class="product-title">
  102. <div class="product-icon">
  103. <i class="icon-{{ getProductIconForSpace(space.name) }}"></i>
  104. </div>
  105. <div class="product-details">
  106. <h3 class="product-name">{{ space.name }}</h3>
  107. <div class="product-meta">
  108. <span class="badge" [attr.data-color]="getStatusColorForSpace(space.productId)">
  109. {{ getStatusTextForSpace(space.productId) }}
  110. </span>
  111. <span class="designer-name">{{ getDesignerNameForSpace(space.productId) }}</span>
  112. </div>
  113. </div>
  114. </div>
  115. <div class="product-pricing">
  116. <p class="product-subtotal">{{ formatPrice(calculateSpaceSubtotal(space)) }}</p>
  117. @if (quotation.spaceBreakdown?.length > 1) {
  118. <span class="percentage">{{ formatPercentage(getSpacePercentage(space.productId)) }}</span>
  119. }
  120. </div>
  121. </div>
  122. <div class="product-actions">
  123. @if (canEdit) {
  124. <button class="btn-icon" (click)="editProduct(space.productId); $event.stopPropagation()" title="编辑">
  125. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  126. <path fill="currentColor" d="M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32l39.38 39.38c25 25 25 65.62 0 90.62l-109.5 109.5c-25 25-65.62 25-90.62 0l-109.5-109.5c-25-25-25-65.62 0-90.62L272.1 19.32zM336.5 256.1L227.1 365.5c-12.5 12.5-32.75 12.5-45.25 0s-12.5-32.75 0-45.25l109.5-109.5c12.5-12.5 32.75-12.5 45.25 0S349 243.6 336.5 256.1zM192 416h64v64h-64V416z"/>
  127. </svg>
  128. </button>
  129. <button class="btn-icon danger" (click)="deleteProduct(space.productId); $event.stopPropagation()" title="删除">
  130. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  131. <path fill="currentColor" d="M96 480c0 17.67 14.33 32 32 32h256c17.67 0 32-14.33 32-32V128H96V480zM312 192c13.25 0 24 10.75 24 24v208c0 13.25-10.75 24-24 24c-13.25 0-24-10.75-24-24V216C288 202.8 298.8 192 312 192zM200 192c13.25 0 24 10.75 24 24v208c0 13.25-10.75 24-24 24s-24-10.75-24-24V216C176 202.8 186.8 192 200 192zM472 64h-80V48c0-26.51-21.49-48-48-48h-176C141.5 0 120 21.49 120 48v64H48c-17.67 0-32 14.33-32 32s14.33 32 32 32h32v352c0 26.51 21.49 48 48 48h256c26.51 0 48-21.49 48-48V128h32c17.67 0 32-14.33 32-32S489.7 64 472 64zM168 48h176v16H168V48zm232 400H112V128h288V448z"/>
  132. </svg>
  133. </button>
  134. }
  135. <div class="product-toggle">
  136. <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  137. <path fill="currentColor" d="M256 294.1L383 167c9.4-9.4 24.6-9.4 33.9 0s9.3 24.6 0 34L273 345c-9.1 9.1-23.7 9.3-33.1.7L95 201.1c-4.7-4.7-7-10.9-7-17s2.3-12.3 7-17c9.4-9.4 24.6-9.4 33.9 0l127.1 127z"/>
  138. </svg>
  139. </div>
  140. </div>
  141. </div>
  142. <!-- 产品详情 -->
  143. @if (isProductExpanded(space.name)) {
  144. <div class="product-content">
  145. <!-- 产品信息 -->
  146. @if (getProductForSpace(space.productId)) {
  147. <div class="product-details-section">
  148. <div class="detail-item">
  149. <span class="detail-label">产品类型:</span>
  150. <span class="detail-value">{{ getProductForSpace(space.productId)?.get('productType') }}</span>
  151. </div>
  152. <div class="detail-item">
  153. <span class="detail-label">空间面积:</span>
  154. <span class="detail-value">{{ getProductForSpace(space.productId)?.get('space')?.area || 0 }}㎡</span>
  155. </div>
  156. <div class="detail-item">
  157. <span class="detail-label">复杂度:</span>
  158. <span class="detail-value">{{ getProductForSpace(space.productId)?.get('space')?.complexity || 'medium' }}</span>
  159. </div>
  160. <div class="detail-item">
  161. <span class="detail-label">基础报价:</span>
  162. <span class="detail-value">{{ formatPrice(getProductForSpace(space.productId)?.get('quotation')?.price || 0) }}</span>
  163. </div>
  164. </div>
  165. }
  166. <!-- 工序网格 -->
  167. <div class="process-grid">
  168. @for (processType of processTypes; track processType.key) {
  169. <div
  170. class="process-item"
  171. [class.enabled]="isProcessEnabled(space, processType.key)">
  172. <div class="process-header" (click)="canEdit && toggleProcess(space, processType.key)">
  173. <label class="checkbox-wrapper">
  174. <input
  175. type="checkbox"
  176. class="checkbox-input"
  177. [checked]="isProcessEnabled(space, processType.key)"
  178. [disabled]="!canEdit" />
  179. <span class="checkbox-custom"></span>
  180. </label>
  181. <span class="badge" [attr.data-color]="processType.color">
  182. {{ processType.name }}
  183. </span>
  184. </div>
  185. @if (isProcessEnabled(space, processType.key)) {
  186. <div class="process-inputs">
  187. <div class="input-group">
  188. <label class="input-label">单价</label>
  189. <div class="input-with-note">
  190. <input
  191. class="input-field"
  192. type="number"
  193. [ngModel]="getProcessPrice(space, processType.key)"
  194. (ngModelChange)="setProcessPrice(space, processType.key, $event); onProcessChange()"
  195. [disabled]="!canEdit"
  196. placeholder="0" />
  197. <span class="input-note">元/{{ getProcessUnit(space, processType.key) }}</span>
  198. </div>
  199. </div>
  200. <div class="input-group">
  201. <label class="input-label">数量</label>
  202. <div class="input-with-note">
  203. <input
  204. class="input-field"
  205. type="number"
  206. [ngModel]="getProcessQuantity(space, processType.key)"
  207. (ngModelChange)="setProcessQuantity(space, processType.key, $event); onProcessChange()"
  208. [disabled]="!canEdit"
  209. placeholder="0" />
  210. <span class="input-note">{{ getProcessUnit(space, processType.key) }}</span>
  211. </div>
  212. </div>
  213. <div class="process-subtotal">
  214. 小计: ¥{{ calculateProcessSubtotal(space, processType.key).toFixed(2) }}
  215. </div>
  216. </div>
  217. }
  218. </div>
  219. }
  220. </div>
  221. </div>
  222. }
  223. </div>
  224. }
  225. </div>
  226. }
  227. <!-- 表格视图 -->
  228. @if (viewMode === 'table') {
  229. <div class="quotation-table">
  230. @for (space of quotation.spaces; track space.name) {
  231. <div class="table-section">
  232. <div class="table-header">
  233. <h3 class="table-space-name">{{ space.name }}</h3>
  234. <span class="table-space-subtotal">小计: ¥{{ calculateSpaceSubtotal(space).toFixed(2) }}</span>
  235. </div>
  236. <table class="process-table">
  237. <thead>
  238. <tr>
  239. <th class="col-checkbox"></th>
  240. <th class="col-process">工序</th>
  241. <th class="col-price">单价(元)</th>
  242. <th class="col-quantity">数量</th>
  243. <th class="col-unit">单位</th>
  244. <th class="col-subtotal">小计(元)</th>
  245. </tr>
  246. </thead>
  247. <tbody>
  248. @for (processType of processTypes; track processType.key) {
  249. <tr [class.enabled]="isProcessEnabled(space, processType.key)">
  250. <td class="col-checkbox">
  251. <label class="checkbox-wrapper">
  252. <input
  253. type="checkbox"
  254. [checked]="isProcessEnabled(space, processType.key)"
  255. (change)="canEdit && toggleProcess(space, processType.key)"
  256. [disabled]="!canEdit" />
  257. <span class="checkbox-custom"></span>
  258. </label>
  259. </td>
  260. <td class="col-process">
  261. <span class="badge" [attr.data-color]="processType.color">
  262. {{ processType.name }}
  263. </span>
  264. </td>
  265. <td class="col-price">
  266. @if (isProcessEnabled(space, processType.key)) {
  267. <input
  268. class="table-input"
  269. type="number"
  270. [ngModel]="getProcessPrice(space, processType.key)"
  271. (ngModelChange)="setProcessPrice(space, processType.key, $event); onProcessChange()"
  272. [disabled]="!canEdit"
  273. placeholder="0" />
  274. } @else {
  275. <span class="disabled-value">-</span>
  276. }
  277. </td>
  278. <td class="col-quantity">
  279. @if (isProcessEnabled(space, processType.key)) {
  280. <input
  281. class="table-input"
  282. type="number"
  283. [ngModel]="getProcessQuantity(space, processType.key)"
  284. (ngModelChange)="setProcessQuantity(space, processType.key, $event); onProcessChange()"
  285. [disabled]="!canEdit"
  286. placeholder="0" />
  287. } @else {
  288. <span class="disabled-value">-</span>
  289. }
  290. </td>
  291. <td class="col-unit">
  292. {{ getProcessUnit(space, processType.key) || '-' }}
  293. </td>
  294. <td class="col-subtotal">
  295. @if (isProcessEnabled(space, processType.key)) {
  296. <strong>¥{{ calculateProcessSubtotal(space, processType.key).toFixed(2) }}</strong>
  297. } @else {
  298. <span class="disabled-value">-</span>
  299. }
  300. </td>
  301. </tr>
  302. }
  303. </tbody>
  304. </table>
  305. </div>
  306. }
  307. </div>
  308. }
  309. <!-- 报价汇总 -->
  310. <div class="quotation-summary">
  311. <div class="summary-header">
  312. <h4>报价汇总</h4>
  313. @if (quotation.spaceBreakdown?.length > 1) {
  314. <div class="breakdown-toggle">
  315. <button class="btn-text" (click)="showBreakdown = !showBreakdown">
  316. {{ showBreakdown ? '隐藏' : '显示'}}明细
  317. </button>
  318. </div>
  319. }
  320. </div>
  321. @if (quotation.spaceBreakdown?.length > 1 && showBreakdown) {
  322. <div class="breakdown-list">
  323. @for (item of quotation.spaceBreakdown; track item.spaceId) {
  324. <div class="breakdown-item">
  325. <span class="breakdown-name">{{ item.spaceName }}</span>
  326. <span class="breakdown-amount">{{ formatPrice(item.amount) }}</span>
  327. <span class="breakdown-percentage">{{ formatPercentage(item.percentage) }}</span>
  328. </div>
  329. }
  330. </div>
  331. }
  332. <div class="total-section">
  333. <div class="total-row">
  334. <span class="total-label">报价总额</span>
  335. <span class="total-amount">{{ formatPrice(quotation.total) }}</span>
  336. </div>
  337. @if (quotation.generatedAt) {
  338. <div class="total-meta">
  339. <span class="generate-info">生成于 {{ quotation.generatedAt | date:'yyyy-MM-dd HH:mm' }}</span>
  340. @if (quotation.validUntil) {
  341. <span class="valid-info">有效期至 {{ quotation.validUntil | date:'yyyy-MM-dd' }}</span>
  342. }
  343. </div>
  344. }
  345. </div>
  346. </div>
  347. }
  348. }