@open-mercato/core 0.6.8-develop.6998.1.d2bc46c56c → 0.6.8-develop.7001.1.03608ab252

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (651) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/warranty_claim/index.js +105 -0
  3. package/dist/generated/entities/warranty_claim/index.js.map +7 -0
  4. package/dist/generated/entities/warranty_claim_event/index.js +25 -0
  5. package/dist/generated/entities/warranty_claim_event/index.js.map +7 -0
  6. package/dist/generated/entities/warranty_claim_line/index.js +77 -0
  7. package/dist/generated/entities/warranty_claim_line/index.js.map +7 -0
  8. package/dist/generated/entities/warranty_claim_registration/index.js +43 -0
  9. package/dist/generated/entities/warranty_claim_registration/index.js.map +7 -0
  10. package/dist/generated/entities/warranty_claim_sequence/index.js +17 -0
  11. package/dist/generated/entities/warranty_claim_sequence/index.js.map +7 -0
  12. package/dist/generated/entities/warranty_claim_settings/index.js +43 -0
  13. package/dist/generated/entities/warranty_claim_settings/index.js.map +7 -0
  14. package/dist/generated/entities/warranty_claim_sla_signal/index.js +25 -0
  15. package/dist/generated/entities/warranty_claim_sla_signal/index.js.map +7 -0
  16. package/dist/generated/entities/warranty_troubleshooting_guide/index.js +25 -0
  17. package/dist/generated/entities/warranty_troubleshooting_guide/index.js.map +7 -0
  18. package/dist/generated/entities/warranty_vendor_policy/index.js +31 -0
  19. package/dist/generated/entities/warranty_vendor_policy/index.js.map +7 -0
  20. package/dist/generated/entities.ids.generated.js +12 -0
  21. package/dist/generated/entities.ids.generated.js.map +2 -2
  22. package/dist/generated/entity-fields-registry.js +200 -0
  23. package/dist/generated/entity-fields-registry.js.map +2 -2
  24. package/dist/helpers/integration/api.js +3 -2
  25. package/dist/helpers/integration/api.js.map +2 -2
  26. package/dist/helpers/integration/salesFixtures.js +13 -6
  27. package/dist/helpers/integration/salesFixtures.js.map +2 -2
  28. package/dist/modules/attachments/di.js +18 -0
  29. package/dist/modules/attachments/di.js.map +2 -2
  30. package/dist/modules/attachments/fields/attachment.js +186 -26
  31. package/dist/modules/attachments/fields/attachment.js.map +2 -2
  32. package/dist/modules/attachments/lib/scoped-upload-service.js +202 -0
  33. package/dist/modules/attachments/lib/scoped-upload-service.js.map +7 -0
  34. package/dist/modules/attachments/lib/target-access-service.js +39 -0
  35. package/dist/modules/attachments/lib/target-access-service.js.map +7 -0
  36. package/dist/modules/auth/api/logout.js +37 -10
  37. package/dist/modules/auth/api/logout.js.map +2 -2
  38. package/dist/modules/auth/api/reset/confirm.js +7 -0
  39. package/dist/modules/auth/api/reset/confirm.js.map +2 -2
  40. package/dist/modules/auth/api/reset.js +7 -0
  41. package/dist/modules/auth/api/reset.js.map +2 -2
  42. package/dist/modules/auth/commands/users.js +13 -0
  43. package/dist/modules/auth/commands/users.js.map +2 -2
  44. package/dist/modules/auth/events.js +15 -0
  45. package/dist/modules/auth/events.js.map +2 -2
  46. package/dist/modules/customers/api/companies/route.js +1 -0
  47. package/dist/modules/customers/api/companies/route.js.map +2 -2
  48. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +2 -2
  49. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  50. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +2 -2
  51. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  52. package/dist/modules/customers/components/detail/assignableStaff.js +6 -69
  53. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  54. package/dist/modules/customers/lib/assignableStaff.js +76 -0
  55. package/dist/modules/customers/lib/assignableStaff.js.map +7 -0
  56. package/dist/modules/data_sync/lib/batch-stream.js +56 -0
  57. package/dist/modules/data_sync/lib/batch-stream.js.map +7 -0
  58. package/dist/modules/data_sync/lib/sync-engine.js +165 -103
  59. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  60. package/dist/modules/inbox_ops/api/emails/[id]/reprocess/route.js +4 -0
  61. package/dist/modules/inbox_ops/api/emails/[id]/reprocess/route.js.map +2 -2
  62. package/dist/modules/inbox_ops/api/extract/route.js +4 -0
  63. package/dist/modules/inbox_ops/api/extract/route.js.map +2 -2
  64. package/dist/modules/inbox_ops/api/webhook/inbound.js +4 -0
  65. package/dist/modules/inbox_ops/api/webhook/inbound.js.map +2 -2
  66. package/dist/modules/sales/commands/documents.js +8 -8
  67. package/dist/modules/sales/commands/documents.js.map +2 -2
  68. package/dist/modules/warranty_claims/acl.js +75 -0
  69. package/dist/modules/warranty_claims/acl.js.map +7 -0
  70. package/dist/modules/warranty_claims/ai-agents.js +161 -0
  71. package/dist/modules/warranty_claims/ai-agents.js.map +7 -0
  72. package/dist/modules/warranty_claims/ai-tools.js +591 -0
  73. package/dist/modules/warranty_claims/ai-tools.js.map +7 -0
  74. package/dist/modules/warranty_claims/api/ai/assess/route.js +304 -0
  75. package/dist/modules/warranty_claims/api/ai/assess/route.js.map +7 -0
  76. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js +127 -0
  77. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js.map +7 -0
  78. package/dist/modules/warranty_claims/api/ai/suggest/route.js +177 -0
  79. package/dist/modules/warranty_claims/api/ai/suggest/route.js.map +7 -0
  80. package/dist/modules/warranty_claims/api/assign/route.js +124 -0
  81. package/dist/modules/warranty_claims/api/assign/route.js.map +7 -0
  82. package/dist/modules/warranty_claims/api/assignees/route.js +97 -0
  83. package/dist/modules/warranty_claims/api/assignees/route.js.map +7 -0
  84. package/dist/modules/warranty_claims/api/credit-memo/route.js +152 -0
  85. package/dist/modules/warranty_claims/api/credit-memo/route.js.map +7 -0
  86. package/dist/modules/warranty_claims/api/entitlement/route.js +145 -0
  87. package/dist/modules/warranty_claims/api/entitlement/route.js.map +7 -0
  88. package/dist/modules/warranty_claims/api/events/route.js +199 -0
  89. package/dist/modules/warranty_claims/api/events/route.js.map +7 -0
  90. package/dist/modules/warranty_claims/api/external/claims/route.js +446 -0
  91. package/dist/modules/warranty_claims/api/external/claims/route.js.map +7 -0
  92. package/dist/modules/warranty_claims/api/lines/route.js +258 -0
  93. package/dist/modules/warranty_claims/api/lines/route.js.map +7 -0
  94. package/dist/modules/warranty_claims/api/openapi.js +27 -0
  95. package/dist/modules/warranty_claims/api/openapi.js.map +7 -0
  96. package/dist/modules/warranty_claims/api/portal/attachments/route.js +490 -0
  97. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +7 -0
  98. package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js +178 -0
  99. package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js.map +7 -0
  100. package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js +101 -0
  101. package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js.map +7 -0
  102. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js +78 -0
  103. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js.map +7 -0
  104. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js +81 -0
  105. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js.map +7 -0
  106. package/dist/modules/warranty_claims/api/portal/claims/route.js +462 -0
  107. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +7 -0
  108. package/dist/modules/warranty_claims/api/portal/events/route.js +228 -0
  109. package/dist/modules/warranty_claims/api/portal/events/route.js.map +7 -0
  110. package/dist/modules/warranty_claims/api/portal/options/route.js +91 -0
  111. package/dist/modules/warranty_claims/api/portal/options/route.js.map +7 -0
  112. package/dist/modules/warranty_claims/api/portal/orders/lines/route.js +172 -0
  113. package/dist/modules/warranty_claims/api/portal/orders/lines/route.js.map +7 -0
  114. package/dist/modules/warranty_claims/api/portal/orders/route.js +151 -0
  115. package/dist/modules/warranty_claims/api/portal/orders/route.js.map +7 -0
  116. package/dist/modules/warranty_claims/api/portal/troubleshooting/route.js +121 -0
  117. package/dist/modules/warranty_claims/api/portal/troubleshooting/route.js.map +7 -0
  118. package/dist/modules/warranty_claims/api/receiving/route.js +176 -0
  119. package/dist/modules/warranty_claims/api/receiving/route.js.map +7 -0
  120. package/dist/modules/warranty_claims/api/registrations/route.js +382 -0
  121. package/dist/modules/warranty_claims/api/registrations/route.js.map +7 -0
  122. package/dist/modules/warranty_claims/api/replacement-order/route.js +150 -0
  123. package/dist/modules/warranty_claims/api/replacement-order/route.js.map +7 -0
  124. package/dist/modules/warranty_claims/api/return-label/route.js +235 -0
  125. package/dist/modules/warranty_claims/api/return-label/route.js.map +7 -0
  126. package/dist/modules/warranty_claims/api/risk/route.js +101 -0
  127. package/dist/modules/warranty_claims/api/risk/route.js.map +7 -0
  128. package/dist/modules/warranty_claims/api/route.js +482 -0
  129. package/dist/modules/warranty_claims/api/route.js.map +7 -0
  130. package/dist/modules/warranty_claims/api/sales-return/route.js +151 -0
  131. package/dist/modules/warranty_claims/api/sales-return/route.js.map +7 -0
  132. package/dist/modules/warranty_claims/api/settings-general/route.js +195 -0
  133. package/dist/modules/warranty_claims/api/settings-general/route.js.map +7 -0
  134. package/dist/modules/warranty_claims/api/stats/route.js +199 -0
  135. package/dist/modules/warranty_claims/api/stats/route.js.map +7 -0
  136. package/dist/modules/warranty_claims/api/submit/route.js +120 -0
  137. package/dist/modules/warranty_claims/api/submit/route.js.map +7 -0
  138. package/dist/modules/warranty_claims/api/transition/route.js +126 -0
  139. package/dist/modules/warranty_claims/api/transition/route.js.map +7 -0
  140. package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js +261 -0
  141. package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js.map +7 -0
  142. package/dist/modules/warranty_claims/api/vendor-policies/route.js +311 -0
  143. package/dist/modules/warranty_claims/api/vendor-policies/route.js.map +7 -0
  144. package/dist/modules/warranty_claims/api/vendor-recovery/route.js +130 -0
  145. package/dist/modules/warranty_claims/api/vendor-recovery/route.js.map +7 -0
  146. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js +153 -0
  147. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js.map +7 -0
  148. package/dist/modules/warranty_claims/backend/components/AiAssessButtons.js +268 -0
  149. package/dist/modules/warranty_claims/backend/components/AiAssessButtons.js.map +7 -0
  150. package/dist/modules/warranty_claims/backend/components/ClaimStageProgress.js +54 -0
  151. package/dist/modules/warranty_claims/backend/components/ClaimStageProgress.js.map +7 -0
  152. package/dist/modules/warranty_claims/backend/components/ClaimStatusBadge.js +87 -0
  153. package/dist/modules/warranty_claims/backend/components/ClaimStatusBadge.js.map +7 -0
  154. package/dist/modules/warranty_claims/backend/components/ClaimsKpiStrip.js +120 -0
  155. package/dist/modules/warranty_claims/backend/components/ClaimsKpiStrip.js.map +7 -0
  156. package/dist/modules/warranty_claims/backend/components/EntitlementLookupBadge.js +135 -0
  157. package/dist/modules/warranty_claims/backend/components/EntitlementLookupBadge.js.map +7 -0
  158. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js +218 -0
  159. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js.map +7 -0
  160. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js +231 -0
  161. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js.map +7 -0
  162. package/dist/modules/warranty_claims/backend/components/TroubleshootingWalker.js +82 -0
  163. package/dist/modules/warranty_claims/backend/components/TroubleshootingWalker.js.map +7 -0
  164. package/dist/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.js +136 -0
  165. package/dist/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.js.map +7 -0
  166. package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js +41 -0
  167. package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js.map +7 -0
  168. package/dist/modules/warranty_claims/backend/components/claimReasonOptions.js +45 -0
  169. package/dist/modules/warranty_claims/backend/components/claimReasonOptions.js.map +7 -0
  170. package/dist/modules/warranty_claims/backend/components/claimSla.js +92 -0
  171. package/dist/modules/warranty_claims/backend/components/claimSla.js.map +7 -0
  172. package/dist/modules/warranty_claims/backend/components/orderLookup.js +81 -0
  173. package/dist/modules/warranty_claims/backend/components/orderLookup.js.map +7 -0
  174. package/dist/modules/warranty_claims/backend/components/productLookup.js +317 -0
  175. package/dist/modules/warranty_claims/backend/components/productLookup.js.map +7 -0
  176. package/dist/modules/warranty_claims/backend/components/useUserDisplayNames.js +61 -0
  177. package/dist/modules/warranty_claims/backend/components/useUserDisplayNames.js.map +7 -0
  178. package/dist/modules/warranty_claims/backend/page.js +1088 -0
  179. package/dist/modules/warranty_claims/backend/page.js.map +7 -0
  180. package/dist/modules/warranty_claims/backend/page.meta.js +16 -0
  181. package/dist/modules/warranty_claims/backend/page.meta.js.map +7 -0
  182. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.js +427 -0
  183. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.js.map +7 -0
  184. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.js +16 -0
  185. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.js.map +7 -0
  186. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +2101 -0
  187. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +7 -0
  188. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.js +16 -0
  189. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.js.map +7 -0
  190. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.js +1249 -0
  191. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.js.map +7 -0
  192. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.meta.js +16 -0
  193. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.meta.js.map +7 -0
  194. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.js +110 -0
  195. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.js.map +7 -0
  196. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.js +17 -0
  197. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.js.map +7 -0
  198. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.js +49 -0
  199. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.js.map +7 -0
  200. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.js +17 -0
  201. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.js.map +7 -0
  202. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js +485 -0
  203. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js.map +7 -0
  204. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.js +19 -0
  205. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.js.map +7 -0
  206. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.js +253 -0
  207. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.js.map +7 -0
  208. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js +1565 -0
  209. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js.map +7 -0
  210. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.meta.js +19 -0
  211. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.meta.js.map +7 -0
  212. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.js +472 -0
  213. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.js.map +7 -0
  214. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.js +115 -0
  215. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.js.map +7 -0
  216. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.js +19 -0
  217. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.js.map +7 -0
  218. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.js +54 -0
  219. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.js.map +7 -0
  220. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.js +19 -0
  221. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.js.map +7 -0
  222. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js +414 -0
  223. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js.map +7 -0
  224. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.js +21 -0
  225. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.js.map +7 -0
  226. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.js +171 -0
  227. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.js.map +7 -0
  228. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.js +111 -0
  229. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.js.map +7 -0
  230. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.js +19 -0
  231. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.js.map +7 -0
  232. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.js +48 -0
  233. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.js.map +7 -0
  234. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.js +19 -0
  235. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.js.map +7 -0
  236. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js +429 -0
  237. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js.map +7 -0
  238. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.js +21 -0
  239. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.js.map +7 -0
  240. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.js +182 -0
  241. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.js.map +7 -0
  242. package/dist/modules/warranty_claims/ce.js +48 -0
  243. package/dist/modules/warranty_claims/ce.js.map +7 -0
  244. package/dist/modules/warranty_claims/commands/claim-lines.js +911 -0
  245. package/dist/modules/warranty_claims/commands/claim-lines.js.map +7 -0
  246. package/dist/modules/warranty_claims/commands/claims.js +2577 -0
  247. package/dist/modules/warranty_claims/commands/claims.js.map +7 -0
  248. package/dist/modules/warranty_claims/commands/index.js +4 -0
  249. package/dist/modules/warranty_claims/commands/index.js.map +7 -0
  250. package/dist/modules/warranty_claims/commands/settings.js +169 -0
  251. package/dist/modules/warranty_claims/commands/settings.js.map +7 -0
  252. package/dist/modules/warranty_claims/commands/shared.js +162 -0
  253. package/dist/modules/warranty_claims/commands/shared.js.map +7 -0
  254. package/dist/modules/warranty_claims/data/constants.js +35 -0
  255. package/dist/modules/warranty_claims/data/constants.js.map +7 -0
  256. package/dist/modules/warranty_claims/data/enrichers.js +97 -0
  257. package/dist/modules/warranty_claims/data/enrichers.js.map +7 -0
  258. package/dist/modules/warranty_claims/data/entities.js +731 -0
  259. package/dist/modules/warranty_claims/data/entities.js.map +7 -0
  260. package/dist/modules/warranty_claims/data/validators.js +488 -0
  261. package/dist/modules/warranty_claims/data/validators.js.map +7 -0
  262. package/dist/modules/warranty_claims/di.js +29 -0
  263. package/dist/modules/warranty_claims/di.js.map +7 -0
  264. package/dist/modules/warranty_claims/encryption.js +28 -0
  265. package/dist/modules/warranty_claims/encryption.js.map +7 -0
  266. package/dist/modules/warranty_claims/events.js +81 -0
  267. package/dist/modules/warranty_claims/events.js.map +7 -0
  268. package/dist/modules/warranty_claims/extension-points.js +19 -0
  269. package/dist/modules/warranty_claims/extension-points.js.map +7 -0
  270. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +825 -0
  271. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +7 -0
  272. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.js +11 -0
  273. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.js.map +7 -0
  274. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js +1139 -0
  275. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js.map +7 -0
  276. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.js +11 -0
  277. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.js.map +7 -0
  278. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js +253 -0
  279. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js.map +7 -0
  280. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.js +18 -0
  281. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.js.map +7 -0
  282. package/dist/modules/warranty_claims/index.js +15 -0
  283. package/dist/modules/warranty_claims/index.js.map +7 -0
  284. package/dist/modules/warranty_claims/lib/aiAssist.js +496 -0
  285. package/dist/modules/warranty_claims/lib/aiAssist.js.map +7 -0
  286. package/dist/modules/warranty_claims/lib/apiIdsFilter.js +11 -0
  287. package/dist/modules/warranty_claims/lib/apiIdsFilter.js.map +7 -0
  288. package/dist/modules/warranty_claims/lib/assigneeNames.js +153 -0
  289. package/dist/modules/warranty_claims/lib/assigneeNames.js.map +7 -0
  290. package/dist/modules/warranty_claims/lib/attachmentVisibility.js +9 -0
  291. package/dist/modules/warranty_claims/lib/attachmentVisibility.js.map +7 -0
  292. package/dist/modules/warranty_claims/lib/bulkFeedback.js +8 -0
  293. package/dist/modules/warranty_claims/lib/bulkFeedback.js.map +7 -0
  294. package/dist/modules/warranty_claims/lib/businessHours.js +250 -0
  295. package/dist/modules/warranty_claims/lib/businessHours.js.map +7 -0
  296. package/dist/modules/warranty_claims/lib/businessHoursForm.js +146 -0
  297. package/dist/modules/warranty_claims/lib/businessHoursForm.js.map +7 -0
  298. package/dist/modules/warranty_claims/lib/claimTypeConfig.js +39 -0
  299. package/dist/modules/warranty_claims/lib/claimTypeConfig.js.map +7 -0
  300. package/dist/modules/warranty_claims/lib/decryptionSafety.js +17 -0
  301. package/dist/modules/warranty_claims/lib/decryptionSafety.js.map +7 -0
  302. package/dist/modules/warranty_claims/lib/deskFilters.js +88 -0
  303. package/dist/modules/warranty_claims/lib/deskFilters.js.map +7 -0
  304. package/dist/modules/warranty_claims/lib/dictionaries.js +157 -0
  305. package/dist/modules/warranty_claims/lib/dictionaries.js.map +7 -0
  306. package/dist/modules/warranty_claims/lib/dictionaryLabels.js +12 -0
  307. package/dist/modules/warranty_claims/lib/dictionaryLabels.js.map +7 -0
  308. package/dist/modules/warranty_claims/lib/emailIntake.js +87 -0
  309. package/dist/modules/warranty_claims/lib/emailIntake.js.map +7 -0
  310. package/dist/modules/warranty_claims/lib/escalation.js +58 -0
  311. package/dist/modules/warranty_claims/lib/escalation.js.map +7 -0
  312. package/dist/modules/warranty_claims/lib/externalIntake.js +233 -0
  313. package/dist/modules/warranty_claims/lib/externalIntake.js.map +7 -0
  314. package/dist/modules/warranty_claims/lib/grading.js +21 -0
  315. package/dist/modules/warranty_claims/lib/grading.js.map +7 -0
  316. package/dist/modules/warranty_claims/lib/listSegments.js +18 -0
  317. package/dist/modules/warranty_claims/lib/listSegments.js.map +7 -0
  318. package/dist/modules/warranty_claims/lib/orderOwnership.js +25 -0
  319. package/dist/modules/warranty_claims/lib/orderOwnership.js.map +7 -0
  320. package/dist/modules/warranty_claims/lib/portalAttachmentUpload.js +11 -0
  321. package/dist/modules/warranty_claims/lib/portalAttachmentUpload.js.map +7 -0
  322. package/dist/modules/warranty_claims/lib/portalAttachmentValidation.js +108 -0
  323. package/dist/modules/warranty_claims/lib/portalAttachmentValidation.js.map +7 -0
  324. package/dist/modules/warranty_claims/lib/portalClaimAccess.js +26 -0
  325. package/dist/modules/warranty_claims/lib/portalClaimAccess.js.map +7 -0
  326. package/dist/modules/warranty_claims/lib/quantity.js +19 -0
  327. package/dist/modules/warranty_claims/lib/quantity.js.map +7 -0
  328. package/dist/modules/warranty_claims/lib/risk.js +204 -0
  329. package/dist/modules/warranty_claims/lib/risk.js.map +7 -0
  330. package/dist/modules/warranty_claims/lib/settings.js +55 -0
  331. package/dist/modules/warranty_claims/lib/settings.js.map +7 -0
  332. package/dist/modules/warranty_claims/lib/stateMachine.js +88 -0
  333. package/dist/modules/warranty_claims/lib/stateMachine.js.map +7 -0
  334. package/dist/modules/warranty_claims/lib/timelineFilters.js +16 -0
  335. package/dist/modules/warranty_claims/lib/timelineFilters.js.map +7 -0
  336. package/dist/modules/warranty_claims/lib/triage.js +241 -0
  337. package/dist/modules/warranty_claims/lib/triage.js.map +7 -0
  338. package/dist/modules/warranty_claims/lib/troubleshooting.js +111 -0
  339. package/dist/modules/warranty_claims/lib/troubleshooting.js.map +7 -0
  340. package/dist/modules/warranty_claims/lib/tryResolve.js +11 -0
  341. package/dist/modules/warranty_claims/lib/tryResolve.js.map +7 -0
  342. package/dist/modules/warranty_claims/lib/vendorPolicyRecovery.js +101 -0
  343. package/dist/modules/warranty_claims/lib/vendorPolicyRecovery.js.map +7 -0
  344. package/dist/modules/warranty_claims/lib/warrantyPreview.js +18 -0
  345. package/dist/modules/warranty_claims/lib/warrantyPreview.js.map +7 -0
  346. package/dist/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.js +32 -0
  347. package/dist/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.js.map +7 -0
  348. package/dist/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.js +16 -0
  349. package/dist/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.js.map +7 -0
  350. package/dist/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.js +17 -0
  351. package/dist/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.js.map +7 -0
  352. package/dist/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.js +29 -0
  353. package/dist/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.js.map +7 -0
  354. package/dist/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.js +25 -0
  355. package/dist/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.js.map +7 -0
  356. package/dist/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.js +13 -0
  357. package/dist/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.js.map +7 -0
  358. package/dist/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.js +15 -0
  359. package/dist/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.js.map +7 -0
  360. package/dist/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.js +13 -0
  361. package/dist/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.js.map +7 -0
  362. package/dist/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.js +13 -0
  363. package/dist/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.js.map +7 -0
  364. package/dist/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.js +12 -0
  365. package/dist/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.js.map +7 -0
  366. package/dist/modules/warranty_claims/notifications.client.js +67 -0
  367. package/dist/modules/warranty_claims/notifications.client.js.map +7 -0
  368. package/dist/modules/warranty_claims/notifications.js +103 -0
  369. package/dist/modules/warranty_claims/notifications.js.map +7 -0
  370. package/dist/modules/warranty_claims/search.js +174 -0
  371. package/dist/modules/warranty_claims/search.js.map +7 -0
  372. package/dist/modules/warranty_claims/services/adjudicationEvaluator.js +202 -0
  373. package/dist/modules/warranty_claims/services/adjudicationEvaluator.js.map +7 -0
  374. package/dist/modules/warranty_claims/services/claimNumberGenerator.js +58 -0
  375. package/dist/modules/warranty_claims/services/claimNumberGenerator.js.map +7 -0
  376. package/dist/modules/warranty_claims/services/entitlementResolver.js +69 -0
  377. package/dist/modules/warranty_claims/services/entitlementResolver.js.map +7 -0
  378. package/dist/modules/warranty_claims/services/returnLabelProvider.js +14 -0
  379. package/dist/modules/warranty_claims/services/returnLabelProvider.js.map +7 -0
  380. package/dist/modules/warranty_claims/setup.js +121 -0
  381. package/dist/modules/warranty_claims/setup.js.map +7 -0
  382. package/dist/modules/warranty_claims/subscribers/auto-vendor-recovery.js +119 -0
  383. package/dist/modules/warranty_claims/subscribers/auto-vendor-recovery.js.map +7 -0
  384. package/dist/modules/warranty_claims/subscribers/claim-assigned-notification.js +47 -0
  385. package/dist/modules/warranty_claims/subscribers/claim-assigned-notification.js.map +7 -0
  386. package/dist/modules/warranty_claims/subscribers/claim-customer-reply-notification.js +60 -0
  387. package/dist/modules/warranty_claims/subscribers/claim-customer-reply-notification.js.map +7 -0
  388. package/dist/modules/warranty_claims/subscribers/claim-status-notification.js +83 -0
  389. package/dist/modules/warranty_claims/subscribers/claim-status-notification.js.map +7 -0
  390. package/dist/modules/warranty_claims/subscribers/claim-submitted-notification.js +46 -0
  391. package/dist/modules/warranty_claims/subscribers/claim-submitted-notification.js.map +7 -0
  392. package/dist/modules/warranty_claims/subscribers/email-to-claim.js +135 -0
  393. package/dist/modules/warranty_claims/subscribers/email-to-claim.js.map +7 -0
  394. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +164 -0
  395. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +7 -0
  396. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.js +24 -0
  397. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.js.map +7 -0
  398. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.js +26 -0
  399. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.js.map +7 -0
  400. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/config.js +15 -0
  401. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/config.js.map +7 -0
  402. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.js +197 -0
  403. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.js.map +7 -0
  404. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.js +28 -0
  405. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.js.map +7 -0
  406. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.js +418 -0
  407. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.js.map +7 -0
  408. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.js +18 -0
  409. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.js.map +7 -0
  410. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.js +17 -0
  411. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.js.map +7 -0
  412. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab.js +146 -0
  413. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab.js.map +7 -0
  414. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab/widget.js +17 -0
  415. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab/widget.js.map +7 -0
  416. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab.js +147 -0
  417. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab.js.map +7 -0
  418. package/dist/modules/warranty_claims/widgets/injection-table.js +38 -0
  419. package/dist/modules/warranty_claims/widgets/injection-table.js.map +7 -0
  420. package/dist/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.js +223 -0
  421. package/dist/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.js.map +7 -0
  422. package/dist/modules/warranty_claims/widgets/notifications/index.js +5 -0
  423. package/dist/modules/warranty_claims/widgets/notifications/index.js.map +7 -0
  424. package/dist/modules/warranty_claims/workers/sla-escalation-sweep.js +334 -0
  425. package/dist/modules/warranty_claims/workers/sla-escalation-sweep.js.map +7 -0
  426. package/generated/entities/warranty_claim/index.ts +51 -0
  427. package/generated/entities/warranty_claim_event/index.ts +11 -0
  428. package/generated/entities/warranty_claim_line/index.ts +37 -0
  429. package/generated/entities/warranty_claim_registration/index.ts +20 -0
  430. package/generated/entities/warranty_claim_sequence/index.ts +7 -0
  431. package/generated/entities/warranty_claim_settings/index.ts +20 -0
  432. package/generated/entities/warranty_claim_sla_signal/index.ts +11 -0
  433. package/generated/entities/warranty_troubleshooting_guide/index.ts +11 -0
  434. package/generated/entities/warranty_vendor_policy/index.ts +14 -0
  435. package/generated/entities.ids.generated.ts +12 -0
  436. package/generated/entity-fields-registry.ts +200 -0
  437. package/package.json +7 -7
  438. package/src/helpers/integration/api.ts +10 -3
  439. package/src/helpers/integration/salesFixtures.ts +17 -7
  440. package/src/modules/attachments/di.ts +30 -1
  441. package/src/modules/attachments/fields/attachment.tsx +215 -23
  442. package/src/modules/attachments/i18n/de.json +5 -0
  443. package/src/modules/attachments/i18n/en.json +5 -0
  444. package/src/modules/attachments/i18n/es.json +5 -0
  445. package/src/modules/attachments/i18n/ko.json +5 -0
  446. package/src/modules/attachments/i18n/pl.json +5 -0
  447. package/src/modules/attachments/lib/scoped-upload-service.ts +257 -0
  448. package/src/modules/attachments/lib/target-access-service.ts +57 -0
  449. package/src/modules/auth/api/logout.ts +46 -10
  450. package/src/modules/auth/api/reset/confirm.ts +7 -0
  451. package/src/modules/auth/api/reset.ts +7 -0
  452. package/src/modules/auth/commands/users.ts +22 -0
  453. package/src/modules/auth/events.ts +15 -0
  454. package/src/modules/customers/api/companies/route.ts +1 -0
  455. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +4 -2
  456. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +4 -2
  457. package/src/modules/customers/components/detail/assignableStaff.ts +14 -177
  458. package/src/modules/customers/lib/assignableStaff.ts +177 -0
  459. package/src/modules/data_sync/AGENTS.md +3 -0
  460. package/src/modules/data_sync/lib/batch-stream.ts +117 -0
  461. package/src/modules/data_sync/lib/sync-engine.ts +190 -115
  462. package/src/modules/inbox_ops/api/emails/[id]/reprocess/route.ts +4 -0
  463. package/src/modules/inbox_ops/api/extract/route.ts +4 -0
  464. package/src/modules/inbox_ops/api/webhook/inbound.ts +4 -0
  465. package/src/modules/sales/commands/documents.ts +16 -8
  466. package/src/modules/warranty_claims/AGENTS.md +49 -0
  467. package/src/modules/warranty_claims/acl.ts +71 -0
  468. package/src/modules/warranty_claims/ai-agents.ts +189 -0
  469. package/src/modules/warranty_claims/ai-tools.ts +711 -0
  470. package/src/modules/warranty_claims/api/ai/assess/route.ts +371 -0
  471. package/src/modules/warranty_claims/api/ai/draft-reply/route.ts +149 -0
  472. package/src/modules/warranty_claims/api/ai/suggest/route.ts +190 -0
  473. package/src/modules/warranty_claims/api/assign/route.ts +142 -0
  474. package/src/modules/warranty_claims/api/assignees/route.ts +102 -0
  475. package/src/modules/warranty_claims/api/credit-memo/route.ts +191 -0
  476. package/src/modules/warranty_claims/api/entitlement/route.ts +224 -0
  477. package/src/modules/warranty_claims/api/events/route.ts +226 -0
  478. package/src/modules/warranty_claims/api/external/claims/route.ts +528 -0
  479. package/src/modules/warranty_claims/api/lines/route.ts +275 -0
  480. package/src/modules/warranty_claims/api/openapi.ts +28 -0
  481. package/src/modules/warranty_claims/api/portal/attachments/route.ts +547 -0
  482. package/src/modules/warranty_claims/api/portal/claims/[id]/route.ts +195 -0
  483. package/src/modules/warranty_claims/api/portal/claims/[id]/shared.ts +125 -0
  484. package/src/modules/warranty_claims/api/portal/claims/[id]/submit/route.ts +82 -0
  485. package/src/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.ts +87 -0
  486. package/src/modules/warranty_claims/api/portal/claims/route.ts +579 -0
  487. package/src/modules/warranty_claims/api/portal/events/route.ts +260 -0
  488. package/src/modules/warranty_claims/api/portal/options/route.ts +113 -0
  489. package/src/modules/warranty_claims/api/portal/orders/lines/route.ts +258 -0
  490. package/src/modules/warranty_claims/api/portal/orders/route.ts +206 -0
  491. package/src/modules/warranty_claims/api/portal/troubleshooting/route.ts +151 -0
  492. package/src/modules/warranty_claims/api/receiving/route.ts +215 -0
  493. package/src/modules/warranty_claims/api/registrations/route.ts +417 -0
  494. package/src/modules/warranty_claims/api/replacement-order/route.ts +188 -0
  495. package/src/modules/warranty_claims/api/return-label/route.ts +291 -0
  496. package/src/modules/warranty_claims/api/risk/route.ts +112 -0
  497. package/src/modules/warranty_claims/api/route.ts +543 -0
  498. package/src/modules/warranty_claims/api/sales-return/route.ts +183 -0
  499. package/src/modules/warranty_claims/api/settings-general/route.ts +230 -0
  500. package/src/modules/warranty_claims/api/stats/route.ts +306 -0
  501. package/src/modules/warranty_claims/api/submit/route.ts +146 -0
  502. package/src/modules/warranty_claims/api/transition/route.ts +144 -0
  503. package/src/modules/warranty_claims/api/troubleshooting-guides/route.ts +295 -0
  504. package/src/modules/warranty_claims/api/vendor-policies/route.ts +351 -0
  505. package/src/modules/warranty_claims/api/vendor-recovery/route.ts +148 -0
  506. package/src/modules/warranty_claims/api/vendor-recovery-suggestions/route.ts +167 -0
  507. package/src/modules/warranty_claims/backend/components/AiAssessButtons.tsx +389 -0
  508. package/src/modules/warranty_claims/backend/components/ClaimStageProgress.tsx +65 -0
  509. package/src/modules/warranty_claims/backend/components/ClaimStatusBadge.tsx +137 -0
  510. package/src/modules/warranty_claims/backend/components/ClaimsKpiStrip.tsx +169 -0
  511. package/src/modules/warranty_claims/backend/components/EntitlementLookupBadge.tsx +203 -0
  512. package/src/modules/warranty_claims/backend/components/ReceivingPanel.tsx +300 -0
  513. package/src/modules/warranty_claims/backend/components/ReturnLabelPanel.tsx +292 -0
  514. package/src/modules/warranty_claims/backend/components/TroubleshootingWalker.tsx +125 -0
  515. package/src/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.tsx +217 -0
  516. package/src/modules/warranty_claims/backend/components/WarrantyWorkspace.tsx +66 -0
  517. package/src/modules/warranty_claims/backend/components/claimReasonOptions.ts +57 -0
  518. package/src/modules/warranty_claims/backend/components/claimSla.tsx +127 -0
  519. package/src/modules/warranty_claims/backend/components/orderLookup.ts +109 -0
  520. package/src/modules/warranty_claims/backend/components/productLookup.tsx +433 -0
  521. package/src/modules/warranty_claims/backend/components/useUserDisplayNames.ts +66 -0
  522. package/src/modules/warranty_claims/backend/page.meta.ts +12 -0
  523. package/src/modules/warranty_claims/backend/page.tsx +1302 -0
  524. package/src/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.ts +12 -0
  525. package/src/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.tsx +531 -0
  526. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.ts +12 -0
  527. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +2599 -0
  528. package/src/modules/warranty_claims/backend/warranty_claims/create/page.meta.ts +12 -0
  529. package/src/modules/warranty_claims/backend/warranty_claims/create/page.tsx +1533 -0
  530. package/src/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.ts +13 -0
  531. package/src/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.tsx +136 -0
  532. package/src/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.ts +13 -0
  533. package/src/modules/warranty_claims/backend/warranty_claims/registrations/create/page.tsx +52 -0
  534. package/src/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.ts +15 -0
  535. package/src/modules/warranty_claims/backend/warranty_claims/registrations/page.tsx +555 -0
  536. package/src/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.tsx +295 -0
  537. package/src/modules/warranty_claims/backend/warranty_claims/settings/page.meta.ts +15 -0
  538. package/src/modules/warranty_claims/backend/warranty_claims/settings/page.tsx +1903 -0
  539. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.tsx +550 -0
  540. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.ts +15 -0
  541. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.tsx +141 -0
  542. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.ts +15 -0
  543. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.tsx +57 -0
  544. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.ts +17 -0
  545. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.tsx +477 -0
  546. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.tsx +214 -0
  547. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.ts +15 -0
  548. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.tsx +137 -0
  549. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.ts +15 -0
  550. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.tsx +51 -0
  551. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.ts +17 -0
  552. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.tsx +506 -0
  553. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.tsx +220 -0
  554. package/src/modules/warranty_claims/ce.ts +44 -0
  555. package/src/modules/warranty_claims/commands/claim-lines.ts +1082 -0
  556. package/src/modules/warranty_claims/commands/claims.ts +3364 -0
  557. package/src/modules/warranty_claims/commands/index.ts +3 -0
  558. package/src/modules/warranty_claims/commands/settings.ts +198 -0
  559. package/src/modules/warranty_claims/commands/shared.ts +212 -0
  560. package/src/modules/warranty_claims/data/constants.ts +37 -0
  561. package/src/modules/warranty_claims/data/enrichers.ts +165 -0
  562. package/src/modules/warranty_claims/data/entities.ts +663 -0
  563. package/src/modules/warranty_claims/data/validators.ts +634 -0
  564. package/src/modules/warranty_claims/di.ts +34 -0
  565. package/src/modules/warranty_claims/encryption.ts +26 -0
  566. package/src/modules/warranty_claims/events.ts +81 -0
  567. package/src/modules/warranty_claims/extension-points.ts +16 -0
  568. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.ts +9 -0
  569. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +1037 -0
  570. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.ts +9 -0
  571. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.tsx +1409 -0
  572. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.ts +16 -0
  573. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.tsx +304 -0
  574. package/src/modules/warranty_claims/i18n/de.json +1270 -0
  575. package/src/modules/warranty_claims/i18n/en.json +1270 -0
  576. package/src/modules/warranty_claims/i18n/es.json +1270 -0
  577. package/src/modules/warranty_claims/i18n/ko.json +1270 -0
  578. package/src/modules/warranty_claims/i18n/pl.json +1270 -0
  579. package/src/modules/warranty_claims/index.ts +13 -0
  580. package/src/modules/warranty_claims/lib/aiAssist.ts +677 -0
  581. package/src/modules/warranty_claims/lib/apiIdsFilter.ts +12 -0
  582. package/src/modules/warranty_claims/lib/assigneeNames.ts +184 -0
  583. package/src/modules/warranty_claims/lib/attachmentVisibility.ts +5 -0
  584. package/src/modules/warranty_claims/lib/bulkFeedback.ts +6 -0
  585. package/src/modules/warranty_claims/lib/businessHours.ts +333 -0
  586. package/src/modules/warranty_claims/lib/businessHoursForm.ts +206 -0
  587. package/src/modules/warranty_claims/lib/claimTypeConfig.ts +49 -0
  588. package/src/modules/warranty_claims/lib/decryptionSafety.ts +19 -0
  589. package/src/modules/warranty_claims/lib/deskFilters.ts +131 -0
  590. package/src/modules/warranty_claims/lib/dictionaries.ts +212 -0
  591. package/src/modules/warranty_claims/lib/dictionaryLabels.ts +16 -0
  592. package/src/modules/warranty_claims/lib/emailIntake.ts +123 -0
  593. package/src/modules/warranty_claims/lib/escalation.ts +83 -0
  594. package/src/modules/warranty_claims/lib/externalIntake.ts +362 -0
  595. package/src/modules/warranty_claims/lib/grading.ts +23 -0
  596. package/src/modules/warranty_claims/lib/listSegments.ts +19 -0
  597. package/src/modules/warranty_claims/lib/orderOwnership.ts +58 -0
  598. package/src/modules/warranty_claims/lib/portalAttachmentUpload.ts +11 -0
  599. package/src/modules/warranty_claims/lib/portalAttachmentValidation.ts +115 -0
  600. package/src/modules/warranty_claims/lib/portalClaimAccess.ts +37 -0
  601. package/src/modules/warranty_claims/lib/quantity.ts +15 -0
  602. package/src/modules/warranty_claims/lib/risk.ts +371 -0
  603. package/src/modules/warranty_claims/lib/settings.ts +76 -0
  604. package/src/modules/warranty_claims/lib/stateMachine.ts +130 -0
  605. package/src/modules/warranty_claims/lib/timelineFilters.ts +22 -0
  606. package/src/modules/warranty_claims/lib/triage.ts +366 -0
  607. package/src/modules/warranty_claims/lib/troubleshooting.ts +157 -0
  608. package/src/modules/warranty_claims/lib/tryResolve.ts +7 -0
  609. package/src/modules/warranty_claims/lib/vendorPolicyRecovery.ts +182 -0
  610. package/src/modules/warranty_claims/lib/warrantyPreview.ts +20 -0
  611. package/src/modules/warranty_claims/migrations/.snapshot-open-mercato.json +3525 -0
  612. package/src/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.ts +38 -0
  613. package/src/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.ts +17 -0
  614. package/src/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.ts +17 -0
  615. package/src/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.ts +37 -0
  616. package/src/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.ts +25 -0
  617. package/src/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.ts +13 -0
  618. package/src/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.ts +15 -0
  619. package/src/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.ts +13 -0
  620. package/src/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.ts +13 -0
  621. package/src/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.ts +11 -0
  622. package/src/modules/warranty_claims/notifications.client.ts +78 -0
  623. package/src/modules/warranty_claims/notifications.ts +101 -0
  624. package/src/modules/warranty_claims/search.ts +199 -0
  625. package/src/modules/warranty_claims/services/adjudicationEvaluator.ts +344 -0
  626. package/src/modules/warranty_claims/services/claimNumberGenerator.ts +69 -0
  627. package/src/modules/warranty_claims/services/entitlementResolver.ts +102 -0
  628. package/src/modules/warranty_claims/services/returnLabelProvider.ts +30 -0
  629. package/src/modules/warranty_claims/setup.ts +154 -0
  630. package/src/modules/warranty_claims/subscribers/auto-vendor-recovery.ts +140 -0
  631. package/src/modules/warranty_claims/subscribers/claim-assigned-notification.ts +54 -0
  632. package/src/modules/warranty_claims/subscribers/claim-customer-reply-notification.ts +68 -0
  633. package/src/modules/warranty_claims/subscribers/claim-status-notification.ts +100 -0
  634. package/src/modules/warranty_claims/subscribers/claim-submitted-notification.ts +53 -0
  635. package/src/modules/warranty_claims/subscribers/email-to-claim.ts +175 -0
  636. package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +226 -0
  637. package/src/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.ts +28 -0
  638. package/src/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.ts +31 -0
  639. package/src/modules/warranty_claims/widgets/dashboard/claims-queue/config.ts +15 -0
  640. package/src/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.tsx +237 -0
  641. package/src/modules/warranty_claims/widgets/dashboard/claims-queue/widget.ts +27 -0
  642. package/src/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.tsx +527 -0
  643. package/src/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.ts +37 -0
  644. package/src/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.ts +21 -0
  645. package/src/modules/warranty_claims/widgets/injection/customer-claims-tab.tsx +210 -0
  646. package/src/modules/warranty_claims/widgets/injection/order-claims-tab/widget.ts +21 -0
  647. package/src/modules/warranty_claims/widgets/injection/order-claims-tab.tsx +210 -0
  648. package/src/modules/warranty_claims/widgets/injection-table.ts +36 -0
  649. package/src/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.tsx +253 -0
  650. package/src/modules/warranty_claims/widgets/notifications/index.ts +1 -0
  651. package/src/modules/warranty_claims/workers/sla-escalation-sweep.ts +450 -0
@@ -0,0 +1,2599 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import { useRouter } from 'next/navigation'
6
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
7
+ import { Copy, Hash, Info, MessageSquare, Pencil, RefreshCw, Trash2, UserRound } from 'lucide-react'
8
+ import { hasFeature } from '@open-mercato/shared/security/features'
9
+ import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
10
+ import type { TranslateFn, TranslateParams } from '@open-mercato/shared/lib/i18n/context'
11
+ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
12
+ import { formatDateTime, formatRelativeTime } from '@open-mercato/shared/lib/time'
13
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
14
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
15
+ import { RowActions } from '@open-mercato/ui/backend/RowActions'
16
+ import { ActionsDropdown } from '@open-mercato/ui/backend/forms/ActionsDropdown'
17
+ import { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'
18
+ import { CrudForm, type CrudField, type CrudFieldOption, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
19
+ import { Button } from '@open-mercato/ui/primitives/button'
20
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
21
+ import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
22
+ import { Input } from '@open-mercato/ui/primitives/input'
23
+ import { Label } from '@open-mercato/ui/primitives/label'
24
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
25
+ import { Textarea } from '@open-mercato/ui/primitives/textarea'
26
+ import { SegmentedControl, SegmentedControlItem } from '@open-mercato/ui/primitives/segmented-control'
27
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select'
28
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
29
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
30
+ // Lazy-load the chat surface so the claim detail route chunk does not carry the AI
31
+ // SDK runtime for every operator — it only renders once a triage chat session is open.
32
+ const LazyAiChat = React.lazy(async () => {
33
+ const mod = await import('@open-mercato/ui/ai/AiChat')
34
+ return { default: mod.AiChat }
35
+ })
36
+ import { AiIcon } from '@open-mercato/ui/ai/AiIcon'
37
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
38
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
39
+ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
40
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
41
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
42
+ import { createCrud, deleteCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
43
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
44
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
45
+ import { mapCrudServerErrorToFormErrors } from '@open-mercato/ui/backend/utils/serverErrors'
46
+ import { useCurrentUserId } from '@open-mercato/ui/backend/utils/useCurrentUserId'
47
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
48
+ import { AttachmentInput } from '@open-mercato/core/modules/attachments/fields/attachment'
49
+ import {
50
+ fetchAssignableStaffMembersPage,
51
+ type AssignableStaffMember,
52
+ } from '@open-mercato/core/modules/customers/lib/assignableStaff'
53
+ import { CLAIM_STATUS_TRANSITIONS } from '../../../data/constants'
54
+ import {
55
+ ClaimStatusBadge,
56
+ type ClaimLineStatus,
57
+ type ClaimStatus,
58
+ } from '../../components/ClaimStatusBadge'
59
+ import { ClaimSlaIndicator } from '../../components/claimSla'
60
+ import {
61
+ ClaimLineProductPicker,
62
+ EntitlementChip,
63
+ computeEntitlementPreview,
64
+ type ClaimProductPick,
65
+ } from '../../components/productLookup'
66
+ import { AiAssessButtons } from '../../components/AiAssessButtons'
67
+ import { useUserDisplayNames } from '../../components/useUserDisplayNames'
68
+ import { ReceivingPanel } from '../../components/ReceivingPanel'
69
+ import { ReturnLabelPanel } from '../../components/ReturnLabelPanel'
70
+ import { VendorRecoverySuggestionsPanel } from '../../components/VendorRecoverySuggestionsPanel'
71
+ import { ClaimStageProgress } from '../../components/ClaimStageProgress'
72
+ import { claimTypeAllowsLineFinancialAdjustments, resolveClaimTypeUiConfig } from '../../../lib/claimTypeConfig'
73
+ import { isTerminal } from '../../../lib/stateMachine'
74
+ import { isCustomerVisibleAttachment } from '../../../lib/attachmentVisibility'
75
+ import { formatQuantity, parseQuantity, quantityInputValue } from '../../../lib/quantity'
76
+ import { localizeDictionaryLabel, type DictionaryLabelKind } from '../../../lib/dictionaryLabels'
77
+ import {
78
+ TIMELINE_FILTERS,
79
+ filterTimelineEvents,
80
+ isTimelineFilterId,
81
+ type TimelineFilterId,
82
+ } from '../../../lib/timelineFilters'
83
+
84
+ type ClaimType = 'warranty' | 'return' | 'core_return' | 'vendor_recovery'
85
+ type ClaimChannel = 'staff' | 'portal' | 'api'
86
+ type ClaimPriority = 'low' | 'normal' | 'high' | 'urgent'
87
+ type Disposition =
88
+ | 'restock'
89
+ | 'repair'
90
+ | 'replace'
91
+ | 'credit'
92
+ | 'refund'
93
+ | 'field_destroy'
94
+ | 'scrap'
95
+ | 'return_to_vendor'
96
+ | 'deny'
97
+
98
+ type TriageMessage = {
99
+ messageKey: string
100
+ params?: TranslateParams
101
+ }
102
+
103
+ type TriageMessageValue = TriageMessage | string
104
+
105
+ type ClaimRiskLevel = 'none' | 'low' | 'medium' | 'high'
106
+
107
+ type ClaimRiskSignal = {
108
+ id: string
109
+ level: 'low' | 'medium' | 'high'
110
+ messageKey: string
111
+ params?: TranslateParams
112
+ relatedClaimNumbers?: string[]
113
+ }
114
+
115
+ type ClaimRiskAssessment = {
116
+ level: ClaimRiskLevel
117
+ signals: ClaimRiskSignal[]
118
+ }
119
+
120
+ type RiskApiResponse = {
121
+ ok?: boolean
122
+ result?: ClaimRiskAssessment
123
+ }
124
+
125
+ type FeatureCheckResponse = {
126
+ ok?: boolean
127
+ granted?: string[]
128
+ }
129
+
130
+ type ClaimRecord = {
131
+ id: string
132
+ claimNumber: string | null
133
+ claimType: ClaimType | string | null
134
+ channel: ClaimChannel | string | null
135
+ status: ClaimStatus | string | null
136
+ priority: ClaimPriority | string | null
137
+ customerId: string | null
138
+ customerName: string | null
139
+ orderId: string | null
140
+ orderNumber: string | null
141
+ salesReturnId: string | null
142
+ replacementOrderId: string | null
143
+ creditMemoId: string | null
144
+ awaitingStaffReply: boolean
145
+ vendorName: string | null
146
+ vendorRef: string | null
147
+ totalClaimedAmount: string | null
148
+ totalApprovedAmount: string | null
149
+ totalRecoveredAmount: string | null
150
+ slaDueAt: string | null
151
+ slaPausedAt: string | null
152
+ submittedAt: string | null
153
+ assigneeUserId: string | null
154
+ assigneeName: string | null
155
+ createdAt: string | null
156
+ updatedAt: string | null
157
+ currencyCode: string | null
158
+ notes: string | null
159
+ reasonCode: string | null
160
+ rejectionReasonCode: string | null
161
+ resolutionSummary: string | null
162
+ returnLabelUrl: string | null
163
+ returnTrackingNumber: string | null
164
+ returnCarrier: string | null
165
+ }
166
+
167
+ type ClaimLine = {
168
+ id: string
169
+ claimId: string | null
170
+ lineNo: number | null
171
+ productId: string | null
172
+ variantId: string | null
173
+ productName: string | null
174
+ orderLineId: string | null
175
+ sku: string | null
176
+ serialNumber: string | null
177
+ purchaseDate: string | null
178
+ warrantyMonths: number | null
179
+ faultCode: string | null
180
+ faultDescription: string | null
181
+ qtyClaimed: string | null
182
+ qtyApproved: string | null
183
+ qtyReceived: string | null
184
+ disposition: Disposition | string | null
185
+ lineStatus: ClaimLineStatus | string | null
186
+ creditAmount: string | null
187
+ restockingFee: string | null
188
+ coreChargeAmount: string | null
189
+ coreCreditAmount: string | null
190
+ vendorClaimLineId: string | null
191
+ conditionOnReceipt: string | null
192
+ conditionGrade: string | null
193
+ quarantineStatus: string | null
194
+ inspectionNotes: string | null
195
+ assessmentPayload: Record<string, unknown> | null
196
+ updatedAt: string | null
197
+ }
198
+
199
+ type ClaimEvent = {
200
+ id: string
201
+ kind: string
202
+ visibility: 'internal' | 'customer' | string
203
+ body: string | null
204
+ payload: Record<string, unknown> | null
205
+ actorUserId: string | null
206
+ actorCustomerId: string | null
207
+ createdAt: string | null
208
+ }
209
+
210
+ type ClaimTriageSuggestion = {
211
+ priority: {
212
+ currentPriority: string
213
+ suggestedPriority: string
214
+ overdue: boolean
215
+ reason: TriageMessageValue
216
+ }
217
+ lines: Array<{
218
+ lineId: string
219
+ lineNo: number
220
+ sku: string | null
221
+ productName: string | null
222
+ serialNumber: string | null
223
+ eligibility: {
224
+ status: string
225
+ reason: TriageMessageValue
226
+ }
227
+ suggestedDisposition: string
228
+ reason: TriageMessageValue
229
+ }>
230
+ risk?: ClaimRiskAssessment
231
+ generatedAt: string
232
+ }
233
+
234
+ type InlineLineUpdateField = 'qtyApproved' | 'disposition' | 'lineStatus'
235
+ type InlineLineUpdateValue = string | null
236
+ type InlineLineSaveHandler = (
237
+ line: ClaimLine,
238
+ field: InlineLineUpdateField,
239
+ value: InlineLineUpdateValue,
240
+ ) => Promise<void>
241
+
242
+ type SelectOption = {
243
+ value: string
244
+ label: string
245
+ }
246
+
247
+ function eligibilityBadgeVariant(status: string): 'success' | 'error' | 'neutral' {
248
+ if (status === 'in_warranty') return 'success'
249
+ if (status === 'out_of_warranty') return 'error'
250
+ return 'neutral'
251
+ }
252
+
253
+ type ClaimFormValues = Partial<ClaimRecord> & Record<string, unknown>
254
+ type ClaimLineFormValues = Partial<ClaimLine> & Record<string, unknown>
255
+ type TransitionFormValues = {
256
+ rejectionReasonCode?: string | null
257
+ resolutionSummary?: string | null
258
+ }
259
+ type VendorRecoveryFormValues = {
260
+ lineIds?: string[]
261
+ vendorName?: string | null
262
+ vendorRef?: string | null
263
+ }
264
+
265
+ type TabId = 'lines' | 'timeline' | 'attachments' | 'ai'
266
+
267
+ const CLAIM_LINE_STATUSES: ClaimLineStatus[] = ['pending', 'approved', 'rejected', 'received', 'inspected', 'resolved']
268
+ const CLAIM_DISPOSITIONS: Disposition[] = [
269
+ 'restock',
270
+ 'repair',
271
+ 'replace',
272
+ 'credit',
273
+ 'refund',
274
+ 'field_destroy',
275
+ 'scrap',
276
+ 'return_to_vendor',
277
+ 'deny',
278
+ ]
279
+ const DICTIONARY_KEYS = {
280
+ faultCodes: 'warranty_claims.warranty_claim_fault_code',
281
+ rejectionReasons: 'warranty_claims.warranty_claim_rejection_reason',
282
+ } as const
283
+ const LINE_EDITABLE_CLAIM_STATUSES = new Set<string>([
284
+ 'draft',
285
+ 'submitted',
286
+ 'in_review',
287
+ 'info_requested',
288
+ 'approved',
289
+ 'received',
290
+ 'inspecting',
291
+ ])
292
+ // Must match the server-side receiving gate (`assertReceivingStatus` accepts only received /
293
+ // inspecting). Showing the controls at approved / awaiting_return guided agents into an
294
+ // operation the API rejects with a wrong-state error (WQA-006 / FLOW-02).
295
+ const RECEIVING_CAPABLE_CLAIM_STATUSES = new Set<string>([
296
+ 'received',
297
+ 'inspecting',
298
+ ])
299
+ const DELETABLE_CLAIM_STATUSES = new Set<string>(['draft', 'cancelled'])
300
+ const LINE_STATUS_TRANSITIONS: Record<ClaimLineStatus, ClaimLineStatus[]> = {
301
+ pending: ['approved', 'rejected'],
302
+ approved: ['received', 'resolved'],
303
+ rejected: [],
304
+ received: ['inspected'],
305
+ inspected: ['resolved'],
306
+ resolved: [],
307
+ }
308
+ const EMPTY_RISK_ASSESSMENT: ClaimRiskAssessment = { level: 'none', signals: [] }
309
+ const UNASSIGNED_SELECT_VALUE = '__unassigned__'
310
+ const CLEAR_SELECT_VALUE = '__clear__'
311
+ const AGENT_ID = 'warranty_claims.claims_assistant'
312
+
313
+ function isRecord(value: unknown): value is Record<string, unknown> {
314
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
315
+ }
316
+
317
+ function toStringOrNull(value: unknown): string | null {
318
+ return typeof value === 'string' && value.trim().length ? value.trim() : null
319
+ }
320
+
321
+ function toNumberOrNull(value: unknown): number | null {
322
+ if (typeof value === 'number' && Number.isFinite(value)) return value
323
+ if (typeof value === 'string' && value.trim().length) {
324
+ const parsed = Number(value)
325
+ return Number.isFinite(parsed) ? parsed : null
326
+ }
327
+ return null
328
+ }
329
+
330
+ function toTranslateParams(value: unknown): TranslateParams | undefined {
331
+ if (!isRecord(value)) return undefined
332
+ const params: TranslateParams = {}
333
+ for (const [key, entry] of Object.entries(value)) {
334
+ if (typeof entry === 'string' || typeof entry === 'number') {
335
+ params[key] = entry
336
+ }
337
+ }
338
+ return Object.keys(params).length ? params : undefined
339
+ }
340
+
341
+ function normalizeRiskSignal(value: unknown): ClaimRiskSignal | null {
342
+ if (!isRecord(value)) return null
343
+ const id = toStringOrNull(value.id)
344
+ const level = toStringOrNull(value.level)
345
+ const messageKey = toStringOrNull(value.messageKey)
346
+ if (!id || !messageKey || (level !== 'low' && level !== 'medium' && level !== 'high')) return null
347
+ const relatedClaimNumbers = Array.isArray(value.relatedClaimNumbers)
348
+ ? value.relatedClaimNumbers.filter((claimNumber): claimNumber is string => typeof claimNumber === 'string' && claimNumber.length > 0)
349
+ : undefined
350
+ return {
351
+ id,
352
+ level,
353
+ messageKey,
354
+ params: toTranslateParams(value.params),
355
+ relatedClaimNumbers: relatedClaimNumbers?.length ? relatedClaimNumbers : undefined,
356
+ }
357
+ }
358
+
359
+ function normalizeRiskAssessment(value: unknown): ClaimRiskAssessment {
360
+ if (!isRecord(value)) return EMPTY_RISK_ASSESSMENT
361
+ const level = toStringOrNull(value.level)
362
+ const normalizedLevel: ClaimRiskLevel =
363
+ level === 'low' || level === 'medium' || level === 'high' || level === 'none'
364
+ ? level
365
+ : 'none'
366
+ const signals = Array.isArray(value.signals)
367
+ ? value.signals.map(normalizeRiskSignal).filter((signal): signal is ClaimRiskSignal => signal !== null)
368
+ : []
369
+ return { level: normalizedLevel, signals }
370
+ }
371
+
372
+ function normalizeRiskResponse(value: unknown): ClaimRiskAssessment {
373
+ if (!isRecord(value)) return EMPTY_RISK_ASSESSMENT
374
+ if ('result' in value) return normalizeRiskAssessment(value.result)
375
+ return normalizeRiskAssessment(value)
376
+ }
377
+
378
+ function isClaimLineStatusValue(value: string | null | undefined): value is ClaimLineStatus {
379
+ return typeof value === 'string' && CLAIM_LINE_STATUSES.includes(value as ClaimLineStatus)
380
+ }
381
+
382
+ function buildLineStatusOptions(status: string | null | undefined, t: TranslateFn): SelectOption[] {
383
+ const current = isClaimLineStatusValue(status) ? status : 'pending'
384
+ const values = Array.from(new Set<ClaimLineStatus>([current, ...LINE_STATUS_TRANSITIONS[current]]))
385
+ return values.map((value) => ({ value, label: t(`warranty_claims.lineStatus.${value}`) }))
386
+ }
387
+
388
+ function buildDispositionOptions(
389
+ allowedDispositions: readonly string[],
390
+ currentDisposition: string | null | undefined,
391
+ t: TranslateFn,
392
+ ): SelectOption[] {
393
+ const values = new Set<string>(
394
+ CLAIM_DISPOSITIONS.filter((disposition) => allowedDispositions.includes(disposition)),
395
+ )
396
+ if (currentDisposition) values.add(currentDisposition)
397
+ return Array.from(values).map((disposition) => ({
398
+ value: disposition,
399
+ label: t(`warranty_claims.disposition.${disposition}`, disposition),
400
+ }))
401
+ }
402
+
403
+ function formatTriageMessage(value: TriageMessageValue, t: TranslateFn): string {
404
+ if (typeof value === 'string') return t(value)
405
+ return t(value.messageKey, value.params)
406
+ }
407
+
408
+ function triageLineHeading(
409
+ suggestedLine: ClaimTriageSuggestion['lines'][number],
410
+ lines: ClaimLine[],
411
+ t: TranslateFn,
412
+ ): string {
413
+ const name = suggestedLine.productName ?? suggestedLine.sku ?? suggestedLine.serialNumber ?? null
414
+ const lineNo = toNumberOrNull(suggestedLine.lineNo)
415
+ ?? lines.find((line) => line.id === suggestedLine.lineId)?.lineNo
416
+ ?? null
417
+ const label = name ?? t('warranty_claims.detail.lines.unnamed', 'Unnamed line')
418
+ return lineNo !== null ? `#${lineNo} ${label}` : label
419
+ }
420
+
421
+ function formatTimelineBody(event: ClaimEvent, t: TranslateFn, userNames: Record<string, string>): string | null {
422
+ if (event.body) return event.body
423
+ const payload = event.payload
424
+ const action = payload ? toStringOrNull(payload.action) : null
425
+ if (event.kind === 'system' && action === 'sla_paused') return t('warranty_claims.timeline.slaPaused')
426
+ if (event.kind === 'system' && action === 'sla_resumed') return t('warranty_claims.timeline.slaResumed')
427
+ if (event.kind === 'system' && action === 'auto_approved') return t('warranty_claims.timeline.autoApproved')
428
+ if (event.kind === 'system' && action === 'ai_assessment_recorded') return t('warranty_claims.timeline.aiAssessmentRecorded')
429
+ if (event.kind === 'system' && action === 'sales_return_created') return t('warranty_claims.timeline.salesReturnCreated')
430
+ if (event.kind === 'system' && action === 'sales_return_orphaned') return t('warranty_claims.timeline.salesReturnOrphaned')
431
+ if (event.kind === 'system' && action === 'undo_sales_return_created') return t('warranty_claims.timeline.undoSalesReturnCreated')
432
+ if (event.kind === 'system' && action === 'replacement_order_created') return t('warranty_claims.timeline.replacementOrderCreated')
433
+ if (event.kind === 'system' && action === 'replacement_order_orphaned') return t('warranty_claims.timeline.replacementOrderOrphaned')
434
+ if (event.kind === 'system' && action === 'undo_replacement_order_created') return t('warranty_claims.timeline.undoReplacementOrderCreated')
435
+ if (event.kind === 'system' && action === 'credit_memo_created') return t('warranty_claims.timeline.creditMemoCreated')
436
+ if (event.kind === 'system' && action === 'credit_memo_orphaned') return t('warranty_claims.timeline.creditMemoOrphaned')
437
+ if (event.kind === 'system' && action === 'undo_credit_memo_created') return t('warranty_claims.timeline.undoCreditMemoCreated')
438
+ if (event.kind === 'assignment') {
439
+ const assigneeUserId = payload ? toStringOrNull(payload.assigneeUserId) : null
440
+ if (!assigneeUserId) return t('warranty_claims.timeline.unassigned')
441
+ return t('warranty_claims.timeline.assignedTo', { name: userNames[assigneeUserId] ?? t('warranty_claims.detail.unknownUser') })
442
+ }
443
+ const from = payload ? toStringOrNull(payload.from) ?? toStringOrNull(payload.fromStatus) : null
444
+ const to = payload ? toStringOrNull(payload.to) ?? toStringOrNull(payload.toStatus) : null
445
+ if (event.kind === 'status_changed' && from && to) {
446
+ const statusLine = `${t(`warranty_claims.status.${from}`)} → ${t(`warranty_claims.status.${to}`)}`
447
+ const systemNote = payload ? toStringOrNull(payload.systemNote) : null
448
+ if (systemNote && systemNote.startsWith('warranty_claims.')) {
449
+ return `${statusLine} — ${t(systemNote)}`
450
+ }
451
+ return statusLine
452
+ }
453
+ return null
454
+ }
455
+
456
+ function resolveTimelineActor(event: ClaimEvent, claim: ClaimRecord, userNames: Record<string, string>, t: TranslateFn): string {
457
+ if (event.actorUserId) return userNames[event.actorUserId] ?? t('warranty_claims.detail.unknownUser')
458
+ if (event.actorCustomerId) return claim.customerName ?? t('warranty_claims.detail.customerActor')
459
+ return t('warranty_claims.detail.systemActor')
460
+ }
461
+
462
+ function translateErrorMessage(err: unknown, t: TranslateFn, fallbackKey: string): string {
463
+ const mappedMessage = mapCrudServerErrorToFormErrors(err).message
464
+ if (typeof mappedMessage === 'string' && mappedMessage.trim().length) return t(mappedMessage, mappedMessage)
465
+ if (err instanceof Error && err.message) return t(err.message, err.message)
466
+ return t(fallbackKey)
467
+ }
468
+
469
+ function buildRiskSignalTitle(signal: ClaimRiskSignal): string | undefined {
470
+ return signal.relatedClaimNumbers?.length ? signal.relatedClaimNumbers.join(', ') : undefined
471
+ }
472
+
473
+ function riskSignalVariant(signal: ClaimRiskSignal): 'warning' | 'error' {
474
+ return signal.level === 'high' ? 'error' : 'warning'
475
+ }
476
+
477
+ function normalizeClaim(value: unknown): ClaimRecord | null {
478
+ if (!isRecord(value)) return null
479
+ const id = toStringOrNull(value.id)
480
+ if (!id) return null
481
+ return {
482
+ id,
483
+ claimNumber: toStringOrNull(value.claimNumber),
484
+ claimType: toStringOrNull(value.claimType),
485
+ channel: toStringOrNull(value.channel),
486
+ status: toStringOrNull(value.status),
487
+ priority: toStringOrNull(value.priority),
488
+ customerId: toStringOrNull(value.customerId),
489
+ customerName: toStringOrNull(value.customerName),
490
+ orderId: toStringOrNull(value.orderId),
491
+ orderNumber: toStringOrNull(value.orderNumber),
492
+ salesReturnId: toStringOrNull(value.salesReturnId),
493
+ replacementOrderId: toStringOrNull(value.replacementOrderId),
494
+ creditMemoId: toStringOrNull(value.creditMemoId),
495
+ awaitingStaffReply: parseBooleanFromUnknown(value.awaitingStaffReply) ?? false,
496
+ vendorName: toStringOrNull(value.vendorName),
497
+ vendorRef: toStringOrNull(value.vendorRef),
498
+ totalClaimedAmount: toStringOrNull(value.totalClaimedAmount),
499
+ totalApprovedAmount: toStringOrNull(value.totalApprovedAmount),
500
+ totalRecoveredAmount: toStringOrNull(value.totalRecoveredAmount),
501
+ slaDueAt: toStringOrNull(value.slaDueAt),
502
+ slaPausedAt: toStringOrNull(value.slaPausedAt),
503
+ submittedAt: toStringOrNull(value.submittedAt),
504
+ assigneeUserId: toStringOrNull(value.assigneeUserId),
505
+ assigneeName: toStringOrNull(value.assigneeName),
506
+ createdAt: toStringOrNull(value.createdAt),
507
+ updatedAt: toStringOrNull(value.updatedAt),
508
+ currencyCode: toStringOrNull(value.currencyCode),
509
+ notes: toStringOrNull(value.notes),
510
+ reasonCode: toStringOrNull(value.reasonCode),
511
+ rejectionReasonCode: toStringOrNull(value.rejectionReasonCode),
512
+ resolutionSummary: toStringOrNull(value.resolutionSummary),
513
+ returnLabelUrl: toStringOrNull(value.returnLabelUrl),
514
+ returnTrackingNumber: toStringOrNull(value.returnTrackingNumber),
515
+ returnCarrier: toStringOrNull(value.returnCarrier),
516
+ }
517
+ }
518
+
519
+ function normalizeLine(value: unknown): ClaimLine | null {
520
+ if (!isRecord(value)) return null
521
+ const id = toStringOrNull(value.id)
522
+ if (!id) return null
523
+ return {
524
+ id,
525
+ claimId: toStringOrNull(value.claimId),
526
+ lineNo: toNumberOrNull(value.lineNo),
527
+ productId: toStringOrNull(value.productId),
528
+ variantId: toStringOrNull(value.variantId),
529
+ productName: toStringOrNull(value.productName),
530
+ orderLineId: toStringOrNull(value.orderLineId),
531
+ sku: toStringOrNull(value.sku),
532
+ serialNumber: toStringOrNull(value.serialNumber),
533
+ purchaseDate: toStringOrNull(value.purchaseDate),
534
+ warrantyMonths: toNumberOrNull(value.warrantyMonths),
535
+ faultCode: toStringOrNull(value.faultCode),
536
+ faultDescription: toStringOrNull(value.faultDescription),
537
+ qtyClaimed: toStringOrNull(value.qtyClaimed),
538
+ qtyApproved: toStringOrNull(value.qtyApproved),
539
+ qtyReceived: toStringOrNull(value.qtyReceived),
540
+ disposition: toStringOrNull(value.disposition),
541
+ lineStatus: toStringOrNull(value.lineStatus),
542
+ creditAmount: toStringOrNull(value.creditAmount),
543
+ restockingFee: toStringOrNull(value.restockingFee),
544
+ coreChargeAmount: toStringOrNull(value.coreChargeAmount),
545
+ coreCreditAmount: toStringOrNull(value.coreCreditAmount),
546
+ vendorClaimLineId: toStringOrNull(value.vendorClaimLineId),
547
+ conditionOnReceipt: toStringOrNull(value.conditionOnReceipt),
548
+ conditionGrade: toStringOrNull(value.conditionGrade),
549
+ quarantineStatus: toStringOrNull(value.quarantineStatus),
550
+ inspectionNotes: toStringOrNull(value.inspectionNotes),
551
+ assessmentPayload: isRecord(value.assessmentPayload) ? value.assessmentPayload : null,
552
+ updatedAt: toStringOrNull(value.updatedAt),
553
+ }
554
+ }
555
+
556
+ function normalizeEvent(value: unknown): ClaimEvent | null {
557
+ if (!isRecord(value)) return null
558
+ const id = toStringOrNull(value.id)
559
+ if (!id) return null
560
+ return {
561
+ id,
562
+ kind: toStringOrNull(value.kind) ?? 'system',
563
+ visibility: toStringOrNull(value.visibility) ?? 'internal',
564
+ body: toStringOrNull(value.body),
565
+ payload: isRecord(value.payload) ? value.payload : null,
566
+ actorUserId: toStringOrNull(value.actorUserId),
567
+ actorCustomerId: toStringOrNull(value.actorCustomerId),
568
+ createdAt: toStringOrNull(value.createdAt),
569
+ }
570
+ }
571
+
572
+ function normalizeDictionaryOption(item: unknown): CrudFieldOption | null {
573
+ if (!isRecord(item)) return null
574
+ const value = toStringOrNull(item.value)
575
+ if (!value) return null
576
+ return { value, label: toStringOrNull(item.label) ?? value }
577
+ }
578
+
579
+ function nullableText(value: unknown): string | null {
580
+ return toStringOrNull(value)
581
+ }
582
+
583
+ function buildConflictError(
584
+ call: { status: number; result: unknown },
585
+ fallbackMessage: string,
586
+ t?: TranslateFn,
587
+ ): Error & Record<string, unknown> {
588
+ const payload = isRecord(call.result) ? call.result : {}
589
+ const rawMessage = typeof payload.error === 'string' ? payload.error : fallbackMessage
590
+ const message = t ? t(rawMessage) : rawMessage
591
+ return Object.assign(new Error(message), { status: call.status }, payload)
592
+ }
593
+
594
+ function readDraftReplyPayload(value: unknown): Record<string, unknown> {
595
+ if (!isRecord(value)) return {}
596
+ return isRecord(value.result) ? value.result : value
597
+ }
598
+
599
+ function isDraftReplyNotConfigured(value: unknown): boolean {
600
+ const payload = readDraftReplyPayload(value)
601
+ return payload.notConfigured === true
602
+ }
603
+
604
+ function readDraftReply(value: unknown): string | null {
605
+ return toStringOrNull(readDraftReplyPayload(value).draft)
606
+ }
607
+
608
+ function readErrorKey(value: unknown): string | null {
609
+ return isRecord(value) ? toStringOrNull(value.error) : null
610
+ }
611
+
612
+ function formatAmount(value: string | null, currencyCode: string | null, fallback: string): string {
613
+ const amount = toNumberOrNull(value)
614
+ if (amount === null) return fallback
615
+ if (!currencyCode) return amount.toLocaleString()
616
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode }).format(amount)
617
+ }
618
+
619
+ function eventIcon(kind: string) {
620
+ if (kind === 'comment') return MessageSquare
621
+ if (kind === 'assignment') return UserRound
622
+ if (kind === 'status_changed') return RefreshCw
623
+ return Info
624
+ }
625
+
626
+ const TIMELINE_FILTER_LABELS: Record<TimelineFilterId, { key: string; fallback: string }> = {
627
+ all: { key: 'warranty_claims.detail.timeline.filter.all', fallback: 'All' },
628
+ comments: { key: 'warranty_claims.detail.timeline.filter.comments', fallback: 'Comments' },
629
+ status_changes: { key: 'warranty_claims.detail.timeline.filter.statusChanges', fallback: 'Status changes' },
630
+ customer_visible: { key: 'warranty_claims.detail.timeline.filter.customerVisible', fallback: 'Customer-visible' },
631
+ }
632
+
633
+ function RiskSignalChips({ signals, t }: { signals: ClaimRiskSignal[]; t: TranslateFn }) {
634
+ if (!signals.length) return null
635
+ return (
636
+ <>
637
+ {signals.map((signal) => (
638
+ <span key={signal.id} className="min-w-0 max-w-full" title={buildRiskSignalTitle(signal)}>
639
+ <StatusBadge
640
+ variant={riskSignalVariant(signal)}
641
+ className="h-auto max-w-full rounded-md py-1 whitespace-normal break-words text-left leading-snug"
642
+ >
643
+ {t(signal.messageKey, signal.params)}
644
+ </StatusBadge>
645
+ </span>
646
+ ))}
647
+ </>
648
+ )
649
+ }
650
+
651
+ export function InlineQtyApprovedCell({
652
+ line,
653
+ disabled,
654
+ label,
655
+ onSave,
656
+ }: {
657
+ line: ClaimLine
658
+ disabled: boolean
659
+ label: string
660
+ onSave: InlineLineSaveHandler
661
+ }) {
662
+ const currentValue = quantityInputValue(line.qtyApproved)
663
+ const [draft, setDraft] = React.useState(currentValue)
664
+ const blurSaveSuppressedRef = React.useRef(false)
665
+
666
+ React.useEffect(() => {
667
+ setDraft(currentValue)
668
+ }, [currentValue])
669
+
670
+ const commit = React.useCallback((value: string) => {
671
+ const nextValue = value.trim().length ? value.trim() : null
672
+ if (parseQuantity(nextValue) === parseQuantity(line.qtyApproved)) return
673
+ void onSave(line, 'qtyApproved', nextValue)
674
+ }, [line, onSave])
675
+
676
+ return (
677
+ <Input
678
+ type="number"
679
+ inputMode="decimal"
680
+ value={draft}
681
+ disabled={disabled}
682
+ aria-label={label}
683
+ className="w-24"
684
+ onClick={(event) => event.stopPropagation()}
685
+ onChange={(event) => setDraft(event.target.value)}
686
+ onFocus={() => { blurSaveSuppressedRef.current = false }}
687
+ onBlur={() => {
688
+ if (blurSaveSuppressedRef.current) {
689
+ blurSaveSuppressedRef.current = false
690
+ return
691
+ }
692
+ commit(draft)
693
+ }}
694
+ onKeyDown={(event) => {
695
+ if (event.key === 'Escape') {
696
+ event.preventDefault()
697
+ blurSaveSuppressedRef.current = true
698
+ setDraft(currentValue)
699
+ event.currentTarget.blur()
700
+ return
701
+ }
702
+ if (event.key === 'Enter') {
703
+ event.preventDefault()
704
+ blurSaveSuppressedRef.current = true
705
+ commit(draft)
706
+ event.currentTarget.blur()
707
+ }
708
+ }}
709
+ />
710
+ )
711
+ }
712
+
713
+ export function InlineLineSelectCell({
714
+ line,
715
+ field,
716
+ value,
717
+ options,
718
+ label,
719
+ emptyLabel,
720
+ disabled,
721
+ onSave,
722
+ }: {
723
+ line: ClaimLine
724
+ field: Exclude<InlineLineUpdateField, 'qtyApproved'>
725
+ value: string | null
726
+ options: SelectOption[]
727
+ label: string
728
+ emptyLabel?: string
729
+ disabled: boolean
730
+ onSave: InlineLineSaveHandler
731
+ }) {
732
+ const selectedValue = value ?? CLEAR_SELECT_VALUE
733
+
734
+ return (
735
+ <div onClick={(event) => event.stopPropagation()}>
736
+ <Select
737
+ value={selectedValue}
738
+ disabled={disabled}
739
+ onValueChange={(nextValue) => {
740
+ const normalizedValue = nextValue === CLEAR_SELECT_VALUE ? null : nextValue
741
+ if (normalizedValue === value) return
742
+ void onSave(line, field, normalizedValue)
743
+ }}
744
+ >
745
+ <SelectTrigger aria-label={label} className="w-36">
746
+ <SelectValue />
747
+ </SelectTrigger>
748
+ <SelectContent>
749
+ {emptyLabel ? <SelectItem value={CLEAR_SELECT_VALUE}>{emptyLabel}</SelectItem> : null}
750
+ {options.map((option) => (
751
+ <SelectItem key={option.value} value={option.value}>
752
+ {option.label}
753
+ </SelectItem>
754
+ ))}
755
+ </SelectContent>
756
+ </Select>
757
+ </div>
758
+ )
759
+ }
760
+
761
+ export default function WarrantyClaimDetailPage({ params }: { params?: { id?: string } }) {
762
+ const t = useT()
763
+ const locale = useLocale()
764
+ const router = useRouter()
765
+ const id = typeof params?.id === 'string' ? params.id : ''
766
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
767
+ const currentUserId = useCurrentUserId()
768
+ const scopeVersion = useOrganizationScopeVersion()
769
+ const [claim, setClaim] = React.useState<ClaimRecord | null>(null)
770
+ const [lines, setLines] = React.useState<ClaimLine[]>([])
771
+ const [events, setEvents] = React.useState<ClaimEvent[]>([])
772
+ const [attachmentCount, setAttachmentCount] = React.useState(0)
773
+ const [loading, setLoading] = React.useState(true)
774
+ const [error, setError] = React.useState<string | null>(null)
775
+ const [notFound, setNotFound] = React.useState(false)
776
+ const [activeTab, setActiveTab] = React.useState<TabId>('lines')
777
+ const [timelineFilter, setTimelineFilter] = React.useState<TimelineFilterId>('all')
778
+ const [customerLink, setCustomerLink] = React.useState<{ customerId: string; href: string } | null>(null)
779
+ const [lineDialog, setLineDialog] = React.useState<{ mode: 'create' } | { mode: 'edit'; line: ClaimLine } | null>(null)
780
+ const [defaultWarrantyMonths, setDefaultWarrantyMonths] = React.useState<number | null>(null)
781
+ const [featureAccess, setFeatureAccess] = React.useState({
782
+ claimManage: false,
783
+ claimDelete: false,
784
+ receivingManage: false,
785
+ salesReturnsCreate: false,
786
+ salesOrdersManage: false,
787
+ salesCreditMemosManage: false,
788
+ })
789
+
790
+ React.useEffect(() => {
791
+ let cancelled = false
792
+ void apiCall<{ result?: { defaultWarrantyMonths?: number | null } }>('/api/warranty_claims/settings-general', { headers: { 'x-om-forbidden-redirect': '0' } }, { fallback: {} })
793
+ .then((call) => {
794
+ if (cancelled || !call.ok) return
795
+ const months = call.result?.result?.defaultWarrantyMonths
796
+ setDefaultWarrantyMonths(typeof months === 'number' && Number.isFinite(months) ? months : null)
797
+ })
798
+ .catch(() => undefined)
799
+ return () => {
800
+ cancelled = true
801
+ }
802
+ }, [scopeVersion])
803
+
804
+ React.useEffect(() => {
805
+ let cancelled = false
806
+ void apiCall<FeatureCheckResponse>(
807
+ '/api/auth/feature-check',
808
+ {
809
+ method: 'POST',
810
+ headers: { 'content-type': 'application/json' },
811
+ body: JSON.stringify({
812
+ features: [
813
+ 'warranty_claims.claim.manage',
814
+ 'warranty_claims.claim.delete',
815
+ 'warranty_claims.receiving.manage',
816
+ 'sales.returns.create',
817
+ 'sales.orders.manage',
818
+ 'sales.credit_memos.manage',
819
+ ],
820
+ }),
821
+ },
822
+ { fallback: { ok: false, granted: [] } },
823
+ )
824
+ .then((call) => {
825
+ if (cancelled || !call.ok) return
826
+ const granted = Array.isArray(call.result?.granted) ? call.result.granted : []
827
+ const allGranted = call.result?.ok === true
828
+ setFeatureAccess({
829
+ claimManage: allGranted || hasFeature(granted, 'warranty_claims.claim.manage'),
830
+ claimDelete: allGranted || hasFeature(granted, 'warranty_claims.claim.delete'),
831
+ receivingManage: allGranted || hasFeature(granted, 'warranty_claims.receiving.manage'),
832
+ salesReturnsCreate: allGranted || hasFeature(granted, 'sales.returns.create'),
833
+ salesOrdersManage: allGranted || hasFeature(granted, 'sales.orders.manage'),
834
+ salesCreditMemosManage: allGranted || hasFeature(granted, 'sales.credit_memos.manage'),
835
+ })
836
+ })
837
+ .catch(() => {
838
+ if (!cancelled) {
839
+ setFeatureAccess({
840
+ claimManage: false,
841
+ claimDelete: false,
842
+ receivingManage: false,
843
+ salesReturnsCreate: false,
844
+ salesOrdersManage: false,
845
+ salesCreditMemosManage: false,
846
+ })
847
+ }
848
+ })
849
+ return () => {
850
+ cancelled = true
851
+ }
852
+ }, [scopeVersion])
853
+ const [transitionDialog, setTransitionDialog] = React.useState<{ toStatus: ClaimStatus } | null>(null)
854
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
855
+ const [commentBody, setCommentBody] = React.useState('')
856
+ const [commentVisibility, setCommentVisibility] = React.useState<'internal' | 'customer'>('internal')
857
+ const [aiSuggestion, setAiSuggestion] = React.useState<ClaimTriageSuggestion | null>(null)
858
+ const [aiLoading, setAiLoading] = React.useState(false)
859
+ const [appliedTriageLineIds, setAppliedTriageLineIds] = React.useState<Set<string>>(new Set())
860
+ const [draftReplyLoading, setDraftReplyLoading] = React.useState(false)
861
+ const [draftReplyHidden, setDraftReplyHidden] = React.useState(false)
862
+ const [riskAssessment, setRiskAssessment] = React.useState<ClaimRiskAssessment>(EMPTY_RISK_ASSESSMENT)
863
+ const [slaAtRiskThresholdPct, setSlaAtRiskThresholdPct] = React.useState<number | undefined>(undefined)
864
+ const [assignDialogOpen, setAssignDialogOpen] = React.useState(false)
865
+ const [assignSearch, setAssignSearch] = React.useState('')
866
+ const [assignOptions, setAssignOptions] = React.useState<AssignableStaffMember[]>([])
867
+ const [assignLoading, setAssignLoading] = React.useState(false)
868
+ const [selectedAssigneeUserId, setSelectedAssigneeUserId] = React.useState<string>(UNASSIGNED_SELECT_VALUE)
869
+ const mutationContextId = React.useMemo(() => `warranty-claim:${id || 'pending'}`, [id])
870
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
871
+ formId: string
872
+ resourceKind: string
873
+ resourceId?: string
874
+ retryLastMutation: () => Promise<boolean>
875
+ }>({
876
+ contextId: mutationContextId,
877
+ blockedMessage: t('warranty_claims.common.saveBlocked'),
878
+ })
879
+
880
+ const mutationContext = React.useMemo(() => ({
881
+ formId: mutationContextId,
882
+ resourceKind: 'warranty_claims.claim',
883
+ resourceId: claim?.id ?? id,
884
+ retryLastMutation,
885
+ }), [claim?.id, id, mutationContextId, retryLastMutation])
886
+
887
+ const loadDictionaryOptions = React.useCallback(async (
888
+ dictionaryKey: string,
889
+ kind: DictionaryLabelKind,
890
+ ): Promise<CrudFieldOption[]> => {
891
+ const dictionaries = await readApiResultOrThrow<{ items?: Array<{ id?: string; key?: string }> }>(
892
+ '/api/dictionaries',
893
+ undefined,
894
+ {
895
+ fallback: { items: [] },
896
+ errorMessage: t('warranty_claims.form.error.dictionaryLoad'),
897
+ },
898
+ )
899
+ const dictionary = (dictionaries.items ?? []).find((item) => item.key === dictionaryKey)
900
+ if (!dictionary?.id) return []
901
+ const entries = await readApiResultOrThrow<{ items?: unknown[] }>(
902
+ `/api/dictionaries/${encodeURIComponent(dictionary.id)}/entries`,
903
+ undefined,
904
+ {
905
+ fallback: { items: [] },
906
+ errorMessage: t('warranty_claims.form.error.dictionaryLoad'),
907
+ },
908
+ )
909
+ return (entries.items ?? [])
910
+ .map(normalizeDictionaryOption)
911
+ .filter((option): option is CrudFieldOption => option !== null)
912
+ .map((option) => ({
913
+ ...option,
914
+ label: localizeDictionaryLabel(t, kind, option.value, option.label),
915
+ }))
916
+ }, [t])
917
+
918
+ const loadData = React.useCallback(async () => {
919
+ if (!id) return
920
+ setLoading(true)
921
+ setError(null)
922
+ setNotFound(false)
923
+ try {
924
+ const [claimPayload, linesPayload, eventsPayload, riskPayload, statsPayload, attachmentsPayload] = await Promise.all([
925
+ readApiResultOrThrow<{ items?: unknown[] }>(
926
+ `/api/warranty_claims?ids=${encodeURIComponent(id)}&page=1&pageSize=1`,
927
+ undefined,
928
+ { fallback: { items: [] }, errorMessage: t('warranty_claims.detail.error.load') },
929
+ ),
930
+ readApiResultOrThrow<{ items?: unknown[] }>(
931
+ `/api/warranty_claims/lines?claimId=${encodeURIComponent(id)}&page=1&pageSize=100`,
932
+ undefined,
933
+ { fallback: { items: [] }, errorMessage: t('warranty_claims.detail.error.loadLines') },
934
+ ),
935
+ readApiResultOrThrow<{ items?: unknown[] }>(
936
+ `/api/warranty_claims/events?claimId=${encodeURIComponent(id)}`,
937
+ undefined,
938
+ { fallback: { items: [] }, errorMessage: t('warranty_claims.detail.error.loadEvents') },
939
+ ),
940
+ readApiResultOrThrow<RiskApiResponse>(
941
+ `/api/warranty_claims/risk?claimId=${encodeURIComponent(id)}`,
942
+ undefined,
943
+ {
944
+ fallback: { ok: true, result: EMPTY_RISK_ASSESSMENT },
945
+ errorMessage: t('warranty_claims.detail.error.loadRisk'),
946
+ },
947
+ ),
948
+ readApiResultOrThrow<{ ok?: boolean; result?: { slaAtRiskThresholdPct?: number } }>(
949
+ '/api/warranty_claims/stats',
950
+ undefined,
951
+ { fallback: { ok: true, result: {} } },
952
+ ),
953
+ readApiResultOrThrow<{ items?: unknown[] }>(
954
+ `/api/attachments?entityId=${encodeURIComponent('warranty_claims:warranty_claim')}&recordId=${encodeURIComponent(id)}`,
955
+ undefined,
956
+ { fallback: { items: [] } },
957
+ ),
958
+ ])
959
+ const nextClaim = (claimPayload.items ?? []).map(normalizeClaim).find((item): item is ClaimRecord => item !== null) ?? null
960
+ if (!nextClaim) {
961
+ setClaim(null)
962
+ setLines([])
963
+ setEvents([])
964
+ setAttachmentCount(0)
965
+ setRiskAssessment(EMPTY_RISK_ASSESSMENT)
966
+ setNotFound(true)
967
+ return
968
+ }
969
+ setClaim(nextClaim)
970
+ setLines((linesPayload.items ?? []).map(normalizeLine).filter((item): item is ClaimLine => item !== null))
971
+ setEvents((eventsPayload.items ?? []).map(normalizeEvent).filter((item): item is ClaimEvent => item !== null))
972
+ setAttachmentCount(Array.isArray(attachmentsPayload.items) ? attachmentsPayload.items.length : 0)
973
+ setRiskAssessment(normalizeRiskResponse(riskPayload))
974
+ const nextThreshold = statsPayload?.result?.slaAtRiskThresholdPct
975
+ setSlaAtRiskThresholdPct(typeof nextThreshold === 'number' ? nextThreshold : undefined)
976
+ } catch (err) {
977
+ setError(err instanceof Error ? err.message : t('warranty_claims.detail.error.load'))
978
+ } finally {
979
+ setLoading(false)
980
+ }
981
+ }, [id, scopeVersion, t])
982
+
983
+ React.useEffect(() => {
984
+ void loadData()
985
+ }, [loadData])
986
+
987
+ useAppEvent('warranty_claims.claim.*', (event) => {
988
+ const eventClaimId = toStringOrNull(event.payload.claimId)
989
+ if (eventClaimId === id) void loadData()
990
+ }, [id, loadData])
991
+
992
+ const referencedUserIds = React.useMemo(() => {
993
+ const collected = new Set<string>()
994
+ if (claim?.assigneeUserId) collected.add(claim.assigneeUserId)
995
+ for (const event of events) {
996
+ if (event.actorUserId) collected.add(event.actorUserId)
997
+ if (event.kind === 'assignment') {
998
+ const assigneeUserId = toStringOrNull(event.payload?.assigneeUserId)
999
+ if (assigneeUserId) collected.add(assigneeUserId)
1000
+ }
1001
+ }
1002
+ return [...collected]
1003
+ }, [claim?.assigneeUserId, events])
1004
+ const userNames = useUserDisplayNames(referencedUserIds)
1005
+
1006
+ React.useEffect(() => {
1007
+ if (!assignDialogOpen) return
1008
+ const controller = new AbortController()
1009
+ setAssignLoading(true)
1010
+ fetchAssignableStaffMembersPage(assignSearch, { page: 1, pageSize: 24, signal: controller.signal })
1011
+ .then((page) => setAssignOptions(page.items))
1012
+ .catch(() => setAssignOptions([]))
1013
+ .finally(() => {
1014
+ if (!controller.signal.aborted) setAssignLoading(false)
1015
+ })
1016
+ return () => controller.abort()
1017
+ }, [assignDialogOpen, assignSearch])
1018
+
1019
+ const claimCustomerId = claim?.customerId ?? null
1020
+ React.useEffect(() => {
1021
+ if (!claimCustomerId || customerLink?.customerId === claimCustomerId) return
1022
+ const controller = new AbortController()
1023
+ const idsParam = encodeURIComponent(claimCustomerId)
1024
+ const hasMatch = (payload: { items?: unknown[] } | null) =>
1025
+ (payload?.items ?? []).some((item) => isRecord(item) && toStringOrNull(item.id) === claimCustomerId)
1026
+ const resolveCustomerHref = async (): Promise<string | null> => {
1027
+ const people = await apiCall<{ items?: unknown[] }>(
1028
+ `/api/customers/people?ids=${idsParam}&pageSize=1`,
1029
+ { signal: controller.signal },
1030
+ { fallback: { items: [] } },
1031
+ )
1032
+ if (people.ok && hasMatch(people.result)) return `/backend/customers/people/${claimCustomerId}`
1033
+ const companies = await apiCall<{ items?: unknown[] }>(
1034
+ `/api/customers/companies?ids=${idsParam}&pageSize=1`,
1035
+ { signal: controller.signal },
1036
+ { fallback: { items: [] } },
1037
+ )
1038
+ if (companies.ok && hasMatch(companies.result)) return `/backend/customers/companies/${claimCustomerId}`
1039
+ return null
1040
+ }
1041
+ resolveCustomerHref()
1042
+ .then((href) => {
1043
+ if (controller.signal.aborted || !href) return
1044
+ setCustomerLink({ customerId: claimCustomerId, href })
1045
+ })
1046
+ .catch(() => {})
1047
+ return () => controller.abort()
1048
+ }, [claimCustomerId, customerLink])
1049
+
1050
+ const filteredTimelineEvents = React.useMemo(
1051
+ () => filterTimelineEvents(events, timelineFilter),
1052
+ [events, timelineFilter],
1053
+ )
1054
+
1055
+ const currentStatus = typeof claim?.status === 'string' ? claim.status : 'draft'
1056
+ const nextStatuses = React.useMemo(() => {
1057
+ if (!claim || !(currentStatus in CLAIM_STATUS_TRANSITIONS)) return []
1058
+ return CLAIM_STATUS_TRANSITIONS[currentStatus as ClaimStatus] ?? []
1059
+ }, [claim, currentStatus])
1060
+ const primaryNextStatus = React.useMemo(
1061
+ () => nextStatuses.find((status) => status !== 'cancelled' && status !== 'rejected' && status !== 'info_requested') ?? nextStatuses[0] ?? null,
1062
+ [nextStatuses],
1063
+ )
1064
+ const resolutionStatuses = React.useMemo(
1065
+ () => nextStatuses.filter((status) => status !== primaryNextStatus),
1066
+ [nextStatuses, primaryNextStatus],
1067
+ )
1068
+
1069
+ const noValue = t('warranty_claims.common.noValue')
1070
+ const linesEditable = LINE_EDITABLE_CLAIM_STATUSES.has(currentStatus)
1071
+ const linesInlineEditable = linesEditable && featureAccess.claimManage
1072
+ // FLOW-04 gating. `claimActive` = not fully terminal (closed/cancelled): reassignment and
1073
+ // vendor-recovery (which requires resolved lines) stay available. `claimMutable` is stricter —
1074
+ // it also excludes resolved/rejected, where header fields and lines are server-locked — so the
1075
+ // header "Edit" and AI mutating controls are hidden there instead of leading to a dead form.
1076
+ const claimActive = !isTerminal(currentStatus)
1077
+ const claimMutable = claimActive && currentStatus !== 'resolved' && currentStatus !== 'rejected'
1078
+ const allowedDispositions = React.useMemo(
1079
+ () => resolveClaimTypeUiConfig(claim?.claimType).allowedDispositions,
1080
+ [claim?.claimType],
1081
+ )
1082
+ const currentLineDisposition = lineDialog?.mode === 'edit' ? lineDialog.line.disposition : null
1083
+ const riskSignals = riskAssessment.signals
1084
+ const assigneeDisplayName = claim?.assigneeUserId
1085
+ ? claim.assigneeName ?? userNames[claim.assigneeUserId] ?? t('warranty_claims.detail.unknownUser')
1086
+ : t('warranty_claims.detail.unassigned')
1087
+ const eligibleVendorLines = React.useMemo(
1088
+ () => lines.filter((line) => line.lineStatus === 'resolved' && !line.vendorClaimLineId),
1089
+ [lines],
1090
+ )
1091
+ const assignableOptions = React.useMemo(() => {
1092
+ const byUserId = new Map<string, AssignableStaffMember>()
1093
+ for (const option of assignOptions) byUserId.set(option.userId, option)
1094
+ if (claim?.assigneeUserId && !byUserId.has(claim.assigneeUserId)) {
1095
+ byUserId.set(claim.assigneeUserId, {
1096
+ teamMemberId: claim.assigneeUserId,
1097
+ userId: claim.assigneeUserId,
1098
+ displayName: userNames[claim.assigneeUserId] ?? t('warranty_claims.detail.unknownUser'),
1099
+ email: null,
1100
+ teamName: null,
1101
+ })
1102
+ }
1103
+ return Array.from(byUserId.values())
1104
+ }, [assignOptions, claim?.assigneeUserId, userNames, t])
1105
+
1106
+ const openAssignDialog = React.useCallback(() => {
1107
+ setSelectedAssigneeUserId(claim?.assigneeUserId ?? UNASSIGNED_SELECT_VALUE)
1108
+ setAssignSearch('')
1109
+ setAssignDialogOpen(true)
1110
+ }, [claim?.assigneeUserId])
1111
+
1112
+ const runClaimPost = React.useCallback(async (
1113
+ endpoint: string,
1114
+ body: Record<string, unknown>,
1115
+ successKey: string,
1116
+ ) => {
1117
+ if (!claim) return
1118
+ try {
1119
+ await runMutation({
1120
+ operation: async () => {
1121
+ const call = await withScopedApiRequestHeaders(
1122
+ buildOptimisticLockHeader(claim.updatedAt),
1123
+ () => apiCall(endpoint, {
1124
+ method: 'POST',
1125
+ headers: { 'content-type': 'application/json' },
1126
+ body: JSON.stringify(body),
1127
+ }),
1128
+ )
1129
+ if (!call.ok) throw buildConflictError(call, t('warranty_claims.detail.error.action'), t)
1130
+ return call
1131
+ },
1132
+ context: mutationContext,
1133
+ mutationPayload: body,
1134
+ })
1135
+ flash(t(successKey), 'success')
1136
+ await loadData()
1137
+ } catch (err) {
1138
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1139
+ const message = translateErrorMessage(err, t, 'warranty_claims.detail.error.action')
1140
+ flash(message, 'error')
1141
+ }
1142
+ }, [claim, loadData, mutationContext, runMutation, t])
1143
+
1144
+ const createSalesReturn = React.useCallback(async () => {
1145
+ if (!claim) return
1146
+ const payload = { claimId: claim.id }
1147
+ try {
1148
+ const call = await runMutation({
1149
+ operation: async () => {
1150
+ const response = await withScopedApiRequestHeaders(
1151
+ buildOptimisticLockHeader(claim.updatedAt),
1152
+ () => apiCall<{ salesReturnId?: string; skippedLineIds?: string[] }>('/api/warranty_claims/sales-return', {
1153
+ method: 'POST',
1154
+ headers: { 'content-type': 'application/json' },
1155
+ body: JSON.stringify(payload),
1156
+ }),
1157
+ )
1158
+ if (!response.ok) throw buildConflictError(response, t('warranty_claims.detail.error.action'), t)
1159
+ return response
1160
+ },
1161
+ context: mutationContext,
1162
+ mutationPayload: payload,
1163
+ })
1164
+ flash(t('warranty_claims.detail.createSalesReturnSuccess'), 'success')
1165
+ const skippedCount = call?.result?.skippedLineIds?.length ?? 0
1166
+ if (skippedCount > 0) {
1167
+ flash(t('warranty_claims.detail.createSalesReturnSkipped', undefined, { count: skippedCount }), 'info')
1168
+ }
1169
+ await loadData()
1170
+ } catch (err) {
1171
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1172
+ const message = translateErrorMessage(err, t, 'warranty_claims.detail.error.action')
1173
+ flash(message, 'error')
1174
+ }
1175
+ }, [claim, loadData, mutationContext, runMutation, t])
1176
+
1177
+ const createReplacementOrder = React.useCallback(async () => {
1178
+ if (!claim) return
1179
+ const payload = { claimId: claim.id }
1180
+ try {
1181
+ const call = await runMutation({
1182
+ operation: async () => {
1183
+ const response = await withScopedApiRequestHeaders(
1184
+ buildOptimisticLockHeader(claim.updatedAt),
1185
+ () => apiCall<{ replacementOrderId?: string; skippedLineIds?: string[] }>('/api/warranty_claims/replacement-order', {
1186
+ method: 'POST',
1187
+ headers: { 'content-type': 'application/json' },
1188
+ body: JSON.stringify(payload),
1189
+ }),
1190
+ )
1191
+ if (!response.ok) throw buildConflictError(response, t('warranty_claims.detail.error.action'), t)
1192
+ return response
1193
+ },
1194
+ context: mutationContext,
1195
+ mutationPayload: payload,
1196
+ })
1197
+ flash(t('warranty_claims.detail.createReplacementOrderSuccess'), 'success')
1198
+ const skippedCount = call?.result?.skippedLineIds?.length ?? 0
1199
+ if (skippedCount > 0) {
1200
+ flash(t('warranty_claims.detail.createReplacementOrderSkipped', undefined, { count: skippedCount }), 'info')
1201
+ }
1202
+ await loadData()
1203
+ } catch (err) {
1204
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1205
+ const message = translateErrorMessage(err, t, 'warranty_claims.detail.error.action')
1206
+ flash(message, 'error')
1207
+ }
1208
+ }, [claim, loadData, mutationContext, runMutation, t])
1209
+
1210
+ const createCreditMemo = React.useCallback(async () => {
1211
+ if (!claim) return
1212
+ const payload = { claimId: claim.id }
1213
+ try {
1214
+ const call = await runMutation({
1215
+ operation: async () => {
1216
+ const response = await withScopedApiRequestHeaders(
1217
+ buildOptimisticLockHeader(claim.updatedAt),
1218
+ () => apiCall<{ creditMemoId?: string; skippedLineIds?: string[]; grandTotalGrossAmount?: string; currencyCode?: string }>('/api/warranty_claims/credit-memo', {
1219
+ method: 'POST',
1220
+ headers: { 'content-type': 'application/json' },
1221
+ body: JSON.stringify(payload),
1222
+ }),
1223
+ )
1224
+ if (!response.ok) throw buildConflictError(response, t('warranty_claims.detail.error.action'), t)
1225
+ return response
1226
+ },
1227
+ context: mutationContext,
1228
+ mutationPayload: payload,
1229
+ })
1230
+ flash(
1231
+ t('warranty_claims.detail.createCreditMemoSuccess', undefined, {
1232
+ amount: call?.result?.grandTotalGrossAmount ?? '',
1233
+ currency: call?.result?.currencyCode ?? '',
1234
+ }),
1235
+ 'success',
1236
+ )
1237
+ const skippedCount = call?.result?.skippedLineIds?.length ?? 0
1238
+ if (skippedCount > 0) {
1239
+ flash(t('warranty_claims.detail.createCreditMemoSkipped', undefined, { count: skippedCount }), 'info')
1240
+ }
1241
+ await loadData()
1242
+ } catch (err) {
1243
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1244
+ const message = translateErrorMessage(err, t, 'warranty_claims.detail.error.action')
1245
+ flash(message, 'error')
1246
+ }
1247
+ }, [claim, loadData, mutationContext, runMutation, t])
1248
+
1249
+ const performAssignment = React.useCallback(async (assigneeUserId: string | null) => {
1250
+ if (!claim) return false
1251
+ const payload = { id: claim.id, assigneeUserId }
1252
+ try {
1253
+ await runMutation({
1254
+ operation: async () => {
1255
+ const call = await withScopedApiRequestHeaders(
1256
+ buildOptimisticLockHeader(claim.updatedAt),
1257
+ () => apiCall('/api/warranty_claims/assign', {
1258
+ method: 'POST',
1259
+ headers: { 'content-type': 'application/json' },
1260
+ body: JSON.stringify(payload),
1261
+ }),
1262
+ )
1263
+ if (!call.ok) throw buildConflictError(call, t('warranty_claims.detail.error.action'), t)
1264
+ return call
1265
+ },
1266
+ context: mutationContext,
1267
+ mutationPayload: payload,
1268
+ })
1269
+ flash(t('warranty_claims.list.flash.assigned'), 'success')
1270
+ await loadData()
1271
+ return true
1272
+ } catch (err) {
1273
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return false
1274
+ flash(translateErrorMessage(err, t, 'warranty_claims.detail.error.action'), 'error')
1275
+ return false
1276
+ }
1277
+ }, [claim, loadData, mutationContext, runMutation, t])
1278
+
1279
+ const assignClaim = React.useCallback(async () => {
1280
+ const assigneeUserId = selectedAssigneeUserId === UNASSIGNED_SELECT_VALUE ? null : selectedAssigneeUserId
1281
+ const success = await performAssignment(assigneeUserId)
1282
+ if (success) setAssignDialogOpen(false)
1283
+ }, [performAssignment, selectedAssigneeUserId])
1284
+
1285
+ const assignToMe = React.useCallback(async () => {
1286
+ if (!currentUserId) return
1287
+ await performAssignment(currentUserId)
1288
+ }, [currentUserId, performAssignment])
1289
+
1290
+ const loadAiSuggestion = React.useCallback(async () => {
1291
+ if (!id) return
1292
+ setAiLoading(true)
1293
+ try {
1294
+ const suggestion = await readApiResultOrThrow<ClaimTriageSuggestion>(
1295
+ `/api/warranty_claims/ai/suggest?claimId=${encodeURIComponent(id)}`,
1296
+ undefined,
1297
+ { errorMessage: t('warranty_claims.detail.error.action') },
1298
+ )
1299
+ setAiSuggestion(suggestion)
1300
+ setAppliedTriageLineIds(new Set())
1301
+ } catch (err) {
1302
+ const message = translateErrorMessage(err, t, 'warranty_claims.detail.error.action')
1303
+ flash(message, 'error')
1304
+ } finally {
1305
+ setAiLoading(false)
1306
+ }
1307
+ }, [id, t])
1308
+
1309
+ const applyAiDisposition = React.useCallback(async (lineId: string, disposition: string) => {
1310
+ if (!claim) return
1311
+ const line = lines.find((candidate) => candidate.id === lineId)
1312
+ if (!line) return
1313
+ try {
1314
+ await runMutation({
1315
+ operation: () => withScopedApiRequestHeaders(
1316
+ buildOptimisticLockHeader(line.updatedAt),
1317
+ () => updateCrud('warranty_claims/lines', { id: lineId, claimId: claim.id, disposition }, {
1318
+ errorMessage: t('warranty_claims.detail.lines.error.save'),
1319
+ }),
1320
+ ),
1321
+ context: mutationContext,
1322
+ mutationPayload: { id: lineId, claimId: claim.id, disposition },
1323
+ })
1324
+ flash(t('warranty_claims.detail.lines.flash.saved'), 'success')
1325
+ setAppliedTriageLineIds((prev) => new Set(prev).add(lineId))
1326
+ await loadData()
1327
+ } catch (err) {
1328
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1329
+ const message = translateErrorMessage(err, t, 'warranty_claims.detail.error.action')
1330
+ flash(message, 'error')
1331
+ }
1332
+ }, [claim, lines, loadData, mutationContext, runMutation, t])
1333
+
1334
+ const handleTransition = React.useCallback(async (toStatus: ClaimStatus, values?: TransitionFormValues) => {
1335
+ if (!claim) return
1336
+ await runClaimPost(
1337
+ toStatus === 'submitted' && currentStatus === 'draft'
1338
+ ? '/api/warranty_claims/submit'
1339
+ : '/api/warranty_claims/transition',
1340
+ toStatus === 'submitted' && currentStatus === 'draft'
1341
+ ? { id: claim.id }
1342
+ : {
1343
+ id: claim.id,
1344
+ toStatus,
1345
+ rejectionReasonCode: nullableText(values?.rejectionReasonCode),
1346
+ resolutionSummary: nullableText(values?.resolutionSummary),
1347
+ },
1348
+ 'warranty_claims.detail.flash.transitioned',
1349
+ )
1350
+ }, [claim, currentStatus, runClaimPost])
1351
+
1352
+ const generateSupplierRecovery = React.useCallback(async ({
1353
+ lineIds,
1354
+ vendorName,
1355
+ }: {
1356
+ lineIds: string[]
1357
+ vendorName: string
1358
+ }) => {
1359
+ if (!claim) return
1360
+ if (!lineIds.length || !vendorName.trim()) {
1361
+ flash(t('warranty_claims.errors.vendorRecoveryNeedsResolvedLines'), 'error')
1362
+ return
1363
+ }
1364
+ await runClaimPost(
1365
+ '/api/warranty_claims/vendor-recovery',
1366
+ {
1367
+ claimId: claim.id,
1368
+ lineIds,
1369
+ vendorName: vendorName.trim(),
1370
+ vendorRef: null,
1371
+ },
1372
+ 'warranty_claims.detail.vendorRecovery.created',
1373
+ )
1374
+ }, [claim, runClaimPost, t])
1375
+
1376
+ const confirmAndTransition = React.useCallback(async (toStatus: ClaimStatus) => {
1377
+ if (!claim) return
1378
+ if (toStatus === 'rejected') {
1379
+ setTransitionDialog({ toStatus })
1380
+ return
1381
+ }
1382
+ if (toStatus === 'cancelled') {
1383
+ const confirmed = await confirm({
1384
+ title: t('warranty_claims.detail.confirm.cancelTitle'),
1385
+ variant: 'destructive',
1386
+ })
1387
+ if (!confirmed) return
1388
+ } else {
1389
+ const confirmed = await confirm({
1390
+ title: t('warranty_claims.detail.confirm.transitionTitle'),
1391
+ })
1392
+ if (!confirmed) return
1393
+ }
1394
+ await handleTransition(toStatus)
1395
+ }, [claim, confirm, handleTransition, t])
1396
+
1397
+ const deleteClaim = React.useCallback(async () => {
1398
+ if (!claim || !featureAccess.claimDelete || !DELETABLE_CLAIM_STATUSES.has(currentStatus)) return
1399
+ const confirmed = await confirm({
1400
+ title: t('warranty_claims.audit.claim.delete'),
1401
+ variant: 'destructive',
1402
+ })
1403
+ if (!confirmed) return
1404
+ try {
1405
+ await runMutation({
1406
+ operation: () => withScopedApiRequestHeaders(
1407
+ buildOptimisticLockHeader(claim.updatedAt),
1408
+ () => deleteCrud('warranty_claims', claim.id, {
1409
+ errorMessage: t('warranty_claims.edit.error.delete'),
1410
+ }),
1411
+ ),
1412
+ context: mutationContext,
1413
+ mutationPayload: { id: claim.id },
1414
+ })
1415
+ flash(t('warranty_claims.edit.deleted'), 'success')
1416
+ router.push('/backend/warranty_claims')
1417
+ } catch (err) {
1418
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1419
+ flash(translateErrorMessage(err, t, 'warranty_claims.edit.error.delete'), 'error')
1420
+ }
1421
+ }, [claim, confirm, currentStatus, featureAccess.claimDelete, loadData, mutationContext, router, runMutation, t])
1422
+
1423
+ const headerMoreActions = React.useMemo(() => {
1424
+ if (!claim) return []
1425
+ const items = [
1426
+ ...(claimMutable ? [{
1427
+ id: 'edit',
1428
+ label: t('warranty_claims.detail.actions.edit'),
1429
+ icon: Pencil,
1430
+ onSelect: () => router.push(`/backend/warranty_claims/${claim.id}/edit`),
1431
+ }] : []),
1432
+ {
1433
+ id: 'copy-link',
1434
+ label: t('warranty_claims.detail.copyLink'),
1435
+ icon: Copy,
1436
+ onSelect: async () => {
1437
+ try {
1438
+ await navigator.clipboard.writeText(window.location.href)
1439
+ flash(t('warranty_claims.detail.linkCopied'), 'success')
1440
+ } catch {
1441
+ flash(t('warranty_claims.detail.copyFailed', 'Could not copy to the clipboard.'), 'error')
1442
+ }
1443
+ },
1444
+ },
1445
+ ]
1446
+ if (claim.claimNumber) {
1447
+ items.push({
1448
+ id: 'copy-number',
1449
+ label: t('warranty_claims.detail.copyClaimNumber', 'Copy claim number'),
1450
+ icon: Hash,
1451
+ onSelect: async () => {
1452
+ try {
1453
+ await navigator.clipboard.writeText(claim.claimNumber ?? '')
1454
+ flash(t('warranty_claims.detail.claimNumberCopied', 'Claim number copied.'), 'success')
1455
+ } catch {
1456
+ flash(t('warranty_claims.detail.copyFailed', 'Could not copy to the clipboard.'), 'error')
1457
+ }
1458
+ },
1459
+ })
1460
+ }
1461
+ if (claim.orderId) {
1462
+ items.push({
1463
+ id: 'view-order',
1464
+ label: claim.orderNumber ?? t('warranty_claims.detail.viewOrder'),
1465
+ icon: Copy,
1466
+ onSelect: () => router.push(`/backend/sales/documents/${claim.orderId}`),
1467
+ })
1468
+ }
1469
+ if (featureAccess.claimManage && claimActive) {
1470
+ items.push({
1471
+ id: 'assign',
1472
+ label: t('warranty_claims.detail.reassign'),
1473
+ icon: UserRound,
1474
+ onSelect: openAssignDialog,
1475
+ })
1476
+ if (currentUserId && claim.assigneeUserId !== currentUserId) {
1477
+ items.push({
1478
+ id: 'assign-to-me',
1479
+ label: t('warranty_claims.detail.assignToMe'),
1480
+ icon: UserRound,
1481
+ onSelect: () => { void assignToMe() },
1482
+ })
1483
+ }
1484
+ if (claim.claimType !== 'vendor_recovery' && eligibleVendorLines.length > 0) {
1485
+ items.push({
1486
+ id: 'vendor-recovery',
1487
+ label: t('warranty_claims.detail.actions.vendorRecovery'),
1488
+ icon: RefreshCw,
1489
+ onSelect: () => setVendorDialogOpen(true),
1490
+ })
1491
+ }
1492
+ }
1493
+ return items
1494
+ }, [assignToMe, claim, claimActive, claimMutable, currentUserId, eligibleVendorLines.length, featureAccess.claimManage, openAssignDialog, router, t])
1495
+
1496
+ const submitComment = React.useCallback(async () => {
1497
+ if (!claim || !commentBody.trim()) return
1498
+ try {
1499
+ await runMutation({
1500
+ operation: async () => {
1501
+ const call = await withScopedApiRequestHeaders(
1502
+ buildOptimisticLockHeader(claim.updatedAt),
1503
+ () => apiCall('/api/warranty_claims/events', {
1504
+ method: 'POST',
1505
+ headers: { 'content-type': 'application/json' },
1506
+ body: JSON.stringify({
1507
+ claimId: claim.id,
1508
+ body: commentBody.trim(),
1509
+ visibility: commentVisibility,
1510
+ }),
1511
+ }),
1512
+ )
1513
+ if (!call.ok) throw buildConflictError(call, t('warranty_claims.detail.error.action'), t)
1514
+ return call
1515
+ },
1516
+ context: mutationContext,
1517
+ mutationPayload: {
1518
+ claimId: claim.id,
1519
+ body: commentBody.trim(),
1520
+ visibility: commentVisibility,
1521
+ },
1522
+ })
1523
+ setCommentBody('')
1524
+ flash(t('warranty_claims.detail.flash.commentAdded'), 'success')
1525
+ await loadData()
1526
+ } catch (err) {
1527
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1528
+ const message = translateErrorMessage(err, t, 'warranty_claims.detail.error.comment')
1529
+ flash(message, 'error')
1530
+ }
1531
+ }, [claim, commentBody, commentVisibility, loadData, mutationContext, runMutation, t])
1532
+
1533
+ const draftReplyWithAi = React.useCallback(async () => {
1534
+ if (!claim) return
1535
+ setDraftReplyLoading(true)
1536
+ try {
1537
+ const call = await runMutation({
1538
+ operation: async () => {
1539
+ const draftCall = await apiCall('/api/warranty_claims/ai/draft-reply', {
1540
+ method: 'POST',
1541
+ headers: { 'Content-Type': 'application/json' },
1542
+ body: JSON.stringify({ claimId: claim.id }),
1543
+ })
1544
+ if (isDraftReplyNotConfigured(draftCall.result)) return draftCall
1545
+ if (!draftCall.ok) throw new Error(readErrorKey(draftCall.result) ?? 'warranty_claims.ai.draftError')
1546
+ return draftCall
1547
+ },
1548
+ context: mutationContext,
1549
+ mutationPayload: { claimId: claim.id },
1550
+ })
1551
+ if (isDraftReplyNotConfigured(call.result)) {
1552
+ setDraftReplyHidden(true)
1553
+ return
1554
+ }
1555
+ const draft = readDraftReply(call.result)
1556
+ if (!draft) {
1557
+ flash(t('warranty_claims.ai.draftError'), 'error')
1558
+ return
1559
+ }
1560
+ setCommentBody(draft)
1561
+ setCommentVisibility('customer')
1562
+ } catch (err) {
1563
+ flash(translateErrorMessage(err, t, 'warranty_claims.ai.draftError'), 'error')
1564
+ } finally {
1565
+ setDraftReplyLoading(false)
1566
+ }
1567
+ }, [claim, mutationContext, runMutation, t])
1568
+
1569
+ const saveLineInlineField = React.useCallback<InlineLineSaveHandler>(async (line, field, value) => {
1570
+ if (!claim) return
1571
+ const payload: Record<string, unknown> = { id: line.id, claimId: claim.id, [field]: value }
1572
+ try {
1573
+ await runMutation({
1574
+ operation: () => withScopedApiRequestHeaders(
1575
+ buildOptimisticLockHeader(line.updatedAt),
1576
+ () => updateCrud('warranty_claims/lines', payload, {
1577
+ errorMessage: t('warranty_claims.detail.lines.error.save'),
1578
+ }),
1579
+ ),
1580
+ context: mutationContext,
1581
+ mutationPayload: payload,
1582
+ })
1583
+ flash(t('warranty_claims.detail.lines.flash.saved'), 'success')
1584
+ await loadData()
1585
+ } catch (err) {
1586
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1587
+ flash(translateErrorMessage(err, t, 'warranty_claims.detail.lines.error.save'), 'error')
1588
+ }
1589
+ }, [claim, loadData, mutationContext, runMutation, t])
1590
+
1591
+ const lineFields = React.useMemo<CrudField[]>(() => [
1592
+ {
1593
+ id: 'productLookup',
1594
+ label: t('warranty_claims.form.productLookup'),
1595
+ type: 'custom',
1596
+ layout: 'full',
1597
+ component: ({ values, setFormValue }) => (
1598
+ <ClaimLineProductPicker
1599
+ value={{
1600
+ productId: nullableText(values?.productId),
1601
+ variantId: nullableText(values?.variantId),
1602
+ productName: nullableText(values?.productName),
1603
+ sku: nullableText(values?.sku),
1604
+ }}
1605
+ onPick={(pick: ClaimProductPick) => {
1606
+ setFormValue?.('productId', pick.productId)
1607
+ setFormValue?.('variantId', pick.variantId)
1608
+ setFormValue?.('sku', pick.sku)
1609
+ setFormValue?.('productName', pick.productName)
1610
+ }}
1611
+ onClear={() => {
1612
+ setFormValue?.('productId', null)
1613
+ setFormValue?.('variantId', null)
1614
+ }}
1615
+ hideLabel
1616
+ />
1617
+ ),
1618
+ },
1619
+ { id: 'productName', label: t('warranty_claims.form.productName'), type: 'text', layout: 'half' },
1620
+ { id: 'sku', label: t('warranty_claims.form.sku'), type: 'text', layout: 'half' },
1621
+ { id: 'serialNumber', label: t('warranty_claims.form.serialNumber'), type: 'text', layout: 'half' },
1622
+ { id: 'purchaseDate', label: t('warranty_claims.form.purchaseDate'), type: 'date', layout: 'half' },
1623
+ { id: 'warrantyMonths', label: t('warranty_claims.form.warrantyMonths'), type: 'number', layout: 'half' },
1624
+ {
1625
+ id: 'entitlementPreview',
1626
+ label: t('warranty_claims.form.entitlement'),
1627
+ type: 'custom',
1628
+ layout: 'half',
1629
+ component: ({ values }) => {
1630
+ const purchaseDateText = nullableText(values?.purchaseDate)
1631
+ const purchaseDate = purchaseDateText ? new Date(purchaseDateText) : null
1632
+ const monthsValue = Number(values?.warrantyMonths)
1633
+ const months = Number.isFinite(monthsValue) && String(values?.warrantyMonths ?? '').trim() !== '' ? monthsValue : null
1634
+ return <EntitlementChip status={computeEntitlementPreview(purchaseDate, months)} t={t} />
1635
+ },
1636
+ },
1637
+ {
1638
+ id: 'faultCode',
1639
+ label: t('warranty_claims.form.faultCode'),
1640
+ type: 'select',
1641
+ loadOptions: () => loadDictionaryOptions(DICTIONARY_KEYS.faultCodes, 'fault'),
1642
+ layout: 'half',
1643
+ },
1644
+ { id: 'faultDescription', label: t('warranty_claims.form.faultDescription'), type: 'textarea', rows: 3, layout: 'full' },
1645
+ { id: 'qtyClaimed', label: t('warranty_claims.form.qtyClaimed'), type: 'number', layout: 'third' },
1646
+ { id: 'qtyApproved', label: t('warranty_claims.form.qtyApproved'), type: 'number', layout: 'third' },
1647
+ { id: 'qtyReceived', label: t('warranty_claims.form.qtyReceived'), type: 'number', layout: 'third' },
1648
+ {
1649
+ id: 'lineStatus',
1650
+ label: t('warranty_claims.form.lineStatus'),
1651
+ type: 'select',
1652
+ options: CLAIM_LINE_STATUSES.map((status) => ({ value: status, label: t(`warranty_claims.lineStatus.${status}`) })),
1653
+ layout: 'half',
1654
+ },
1655
+ {
1656
+ id: 'disposition',
1657
+ label: t('warranty_claims.form.disposition'),
1658
+ type: 'select',
1659
+ options: buildDispositionOptions(allowedDispositions, currentLineDisposition, t),
1660
+ layout: 'half',
1661
+ },
1662
+ { id: 'creditAmount', label: t('warranty_claims.form.creditAmount'), type: 'number', layout: 'third' },
1663
+ { id: 'restockingFee', label: t('warranty_claims.form.restockingFee'), type: 'number', layout: 'third' },
1664
+ { id: 'coreChargeAmount', label: t('warranty_claims.form.coreChargeAmount'), type: 'number', layout: 'third' },
1665
+ { id: 'coreCreditAmount', label: t('warranty_claims.form.coreCreditAmount'), type: 'number', layout: 'third' },
1666
+ { id: 'conditionOnReceipt', label: t('warranty_claims.form.conditionOnReceipt'), type: 'textarea', rows: 3, layout: 'full' },
1667
+ { id: 'inspectionNotes', label: t('warranty_claims.form.inspectionNotes'), type: 'textarea', rows: 3, layout: 'full' },
1668
+ ], [allowedDispositions, currentLineDisposition, loadDictionaryOptions, t])
1669
+
1670
+ const lineGroups = React.useMemo<CrudFormGroup[]>(() => [
1671
+ {
1672
+ id: 'line-main',
1673
+ title: t(resolveClaimTypeUiConfig(claim?.claimType).lineHeaderKey),
1674
+ fields: [
1675
+ 'productLookup',
1676
+ 'productName',
1677
+ 'sku',
1678
+ 'serialNumber',
1679
+ 'purchaseDate',
1680
+ 'warrantyMonths',
1681
+ 'entitlementPreview',
1682
+ 'faultCode',
1683
+ 'faultDescription',
1684
+ 'qtyClaimed',
1685
+ 'qtyApproved',
1686
+ 'qtyReceived',
1687
+ 'lineStatus',
1688
+ 'disposition',
1689
+ ],
1690
+ },
1691
+ {
1692
+ id: 'line-money',
1693
+ title: t('warranty_claims.detail.lines.amounts'),
1694
+ // Restock / core fields belong only to return-family claims; warranty & vendor-recovery
1695
+ // settle on the credit amount alone (LINE-05).
1696
+ fields: claimTypeAllowsLineFinancialAdjustments(claim?.claimType)
1697
+ ? ['creditAmount', 'restockingFee', 'coreChargeAmount', 'coreCreditAmount']
1698
+ : ['creditAmount'],
1699
+ },
1700
+ {
1701
+ id: 'line-inspection',
1702
+ title: t('warranty_claims.detail.lines.inspection'),
1703
+ fields: ['conditionOnReceipt', 'inspectionNotes'],
1704
+ },
1705
+ ], [claim?.claimType, t])
1706
+
1707
+ const lineColumns = React.useMemo<ColumnDef<ClaimLine>[]>(() => [
1708
+ {
1709
+ accessorKey: 'lineNo',
1710
+ header: t('warranty_claims.detail.lines.column.number'),
1711
+ cell: ({ row }) => <span className="font-mono text-xs">{row.original.lineNo ?? noValue}</span>,
1712
+ },
1713
+ {
1714
+ accessorKey: 'lineStatus',
1715
+ header: t('warranty_claims.detail.lines.column.status'),
1716
+ cell: ({ row }) => (
1717
+ <InlineLineSelectCell
1718
+ line={row.original}
1719
+ field="lineStatus"
1720
+ value={row.original.lineStatus}
1721
+ label={t('warranty_claims.form.lineStatus')}
1722
+ options={buildLineStatusOptions(row.original.lineStatus, t)}
1723
+ disabled={!linesInlineEditable}
1724
+ onSave={saveLineInlineField}
1725
+ />
1726
+ ),
1727
+ },
1728
+ {
1729
+ accessorKey: 'productName',
1730
+ header: t('warranty_claims.detail.lines.column.product'),
1731
+ cell: ({ row }) => (
1732
+ <div className="max-w-80 space-y-0.5">
1733
+ <div className="truncate font-medium" title={row.original.productName ?? undefined}>{row.original.productName ?? noValue}</div>
1734
+ <div className="truncate text-xs text-muted-foreground">
1735
+ {[row.original.sku, row.original.serialNumber].filter(Boolean).join(' / ') || noValue}
1736
+ </div>
1737
+ </div>
1738
+ ),
1739
+ },
1740
+ {
1741
+ id: 'entitlement',
1742
+ header: t('warranty_claims.detail.lines.column.entitlement'),
1743
+ cell: ({ row }) => {
1744
+ const purchaseDate = row.original.purchaseDate ? new Date(row.original.purchaseDate) : null
1745
+ return (
1746
+ <EntitlementChip
1747
+ status={computeEntitlementPreview(purchaseDate, row.original.warrantyMonths)}
1748
+ t={t}
1749
+ />
1750
+ )
1751
+ },
1752
+ },
1753
+ {
1754
+ accessorKey: 'qtyClaimed',
1755
+ header: t('warranty_claims.lines.header.qtyClaimed'),
1756
+ cell: ({ row }) => formatQuantity(row.original.qtyClaimed, noValue),
1757
+ },
1758
+ {
1759
+ accessorKey: 'qtyApproved',
1760
+ header: t('warranty_claims.lines.header.qtyApproved'),
1761
+ cell: ({ row }) => (
1762
+ <InlineQtyApprovedCell
1763
+ line={row.original}
1764
+ disabled={!linesInlineEditable}
1765
+ label={t('warranty_claims.form.qtyApproved')}
1766
+ onSave={saveLineInlineField}
1767
+ />
1768
+ ),
1769
+ },
1770
+ {
1771
+ accessorKey: 'disposition',
1772
+ header: t('warranty_claims.form.disposition'),
1773
+ cell: ({ row }) => (
1774
+ <InlineLineSelectCell
1775
+ line={row.original}
1776
+ field="disposition"
1777
+ value={row.original.disposition}
1778
+ label={t('warranty_claims.form.disposition')}
1779
+ emptyLabel={noValue}
1780
+ options={buildDispositionOptions(allowedDispositions, row.original.disposition, t)}
1781
+ disabled={!linesInlineEditable}
1782
+ onSave={saveLineInlineField}
1783
+ />
1784
+ ),
1785
+ },
1786
+ {
1787
+ accessorKey: 'creditAmount',
1788
+ header: t('warranty_claims.detail.lines.column.credit'),
1789
+ cell: ({ row }) => formatAmount(row.original.creditAmount, claim?.currencyCode ?? null, noValue),
1790
+ },
1791
+ ], [allowedDispositions, claim?.currencyCode, linesInlineEditable, noValue, saveLineInlineField, t])
1792
+
1793
+ const transitionFields = React.useMemo<CrudField[]>(() => [
1794
+ {
1795
+ id: 'rejectionReasonCode',
1796
+ label: t('warranty_claims.form.rejectionReasonCode'),
1797
+ type: 'select',
1798
+ required: true,
1799
+ loadOptions: () => loadDictionaryOptions(DICTIONARY_KEYS.rejectionReasons, 'rejection'),
1800
+ },
1801
+ {
1802
+ id: 'resolutionSummary',
1803
+ label: t('warranty_claims.form.resolutionSummary'),
1804
+ type: 'textarea',
1805
+ rows: 3,
1806
+ layout: 'full',
1807
+ },
1808
+ ], [loadDictionaryOptions, t])
1809
+
1810
+ const vendorFields = React.useMemo<CrudField[]>(() => [
1811
+ {
1812
+ id: 'lineIds',
1813
+ label: t('warranty_claims.detail.vendorRecovery.lines'),
1814
+ type: 'select',
1815
+ multiple: true,
1816
+ required: true,
1817
+ options: eligibleVendorLines.map((line) => ({
1818
+ value: line.id,
1819
+ label: [
1820
+ t('warranty_claims.form.lines.lineLabel', 'Line {number}', { number: line.lineNo ?? '?' }),
1821
+ line.productName ?? line.sku,
1822
+ ].filter(Boolean).join(' — '),
1823
+ })),
1824
+ },
1825
+ { id: 'vendorName', label: t('warranty_claims.form.vendorName'), type: 'text', required: true },
1826
+ { id: 'vendorRef', label: t('warranty_claims.form.vendorRef'), type: 'text' },
1827
+ ], [eligibleVendorLines, t])
1828
+
1829
+ if (loading) {
1830
+ return (
1831
+ <Page>
1832
+ <PageBody>
1833
+ <LoadingMessage label={t('warranty_claims.detail.loading')} />
1834
+ </PageBody>
1835
+ </Page>
1836
+ )
1837
+ }
1838
+
1839
+ if (notFound) {
1840
+ return (
1841
+ <Page>
1842
+ <PageBody>
1843
+ <RecordNotFoundState
1844
+ label={t('warranty_claims.errors.notFound')}
1845
+ backHref="/backend/warranty_claims"
1846
+ backLabel={t('warranty_claims.detail.actions.backToList')}
1847
+ />
1848
+ </PageBody>
1849
+ </Page>
1850
+ )
1851
+ }
1852
+
1853
+ if (error || !claim) {
1854
+ return (
1855
+ <Page>
1856
+ <PageBody>
1857
+ <ErrorMessage
1858
+ label={error ?? t('warranty_claims.errors.notFound')}
1859
+ action={(
1860
+ <Button asChild variant="outline" size="sm">
1861
+ <Link href="/backend/warranty_claims">{t('warranty_claims.detail.actions.backToList')}</Link>
1862
+ </Button>
1863
+ )}
1864
+ />
1865
+ </PageBody>
1866
+ </Page>
1867
+ )
1868
+ }
1869
+
1870
+ const tabs: Array<{ id: TabId; label: string; count?: number }> = [
1871
+ { id: 'lines', label: t('warranty_claims.detail.tabs.lines'), count: lines.length },
1872
+ { id: 'timeline', label: t('warranty_claims.detail.tabs.timeline'), count: events.length },
1873
+ { id: 'attachments', label: t('warranty_claims.detail.tabs.attachments'), count: attachmentCount },
1874
+ { id: 'ai', label: t('warranty_claims.detail.tabs.ai') },
1875
+ ]
1876
+ const approvedLineCount = lines.filter((line) => line.lineStatus === 'approved').length
1877
+ return (
1878
+ <Page>
1879
+ <PageBody>
1880
+ <div className="space-y-6">
1881
+ <div className="overflow-hidden rounded-xl border border-border bg-card">
1882
+ <div className="px-8 pb-5 pt-5">
1883
+ <div className="flex flex-wrap items-center justify-between gap-3">
1884
+ <div className="inline-flex items-center rounded-md border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
1885
+ {t(`warranty_claims.claimType.${claim.claimType ?? 'warranty'}`)}
1886
+ <span className="ml-1 font-semibold text-foreground">
1887
+ {claim.claimNumber ?? t('warranty_claims.detail.unnumbered', 'Unnumbered claim')}
1888
+ </span>
1889
+ </div>
1890
+ <div className="flex flex-wrap items-center gap-2">
1891
+ {featureAccess.claimDelete ? (
1892
+ <IconButton
1893
+ type="button"
1894
+ variant="outline"
1895
+ size="default"
1896
+ className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
1897
+ disabled={!DELETABLE_CLAIM_STATUSES.has(currentStatus)}
1898
+ aria-label={t('warranty_claims.audit.claim.delete')}
1899
+ title={DELETABLE_CLAIM_STATUSES.has(currentStatus)
1900
+ ? t('warranty_claims.audit.claim.delete')
1901
+ : t('warranty_claims.errors.deleteNotAllowed')}
1902
+ onClick={() => { void deleteClaim() }}
1903
+ >
1904
+ <Trash2 className="size-4" aria-hidden />
1905
+ </IconButton>
1906
+ ) : null}
1907
+ <ActionsDropdown
1908
+ items={headerMoreActions}
1909
+ triggerMode="icon"
1910
+ ariaLabel={t('ui.actions.actions', 'Actions')}
1911
+ />
1912
+ <Select
1913
+ value=""
1914
+ disabled={!featureAccess.claimManage || resolutionStatuses.length === 0}
1915
+ onValueChange={(status) => { void confirmAndTransition(status as ClaimStatus) }}
1916
+ >
1917
+ <SelectTrigger className="h-8 w-28" aria-label={t('warranty_claims.edit.fulfillment.resolution')}>
1918
+ <SelectValue placeholder={t('warranty_claims.edit.fulfillment.resolution')} />
1919
+ </SelectTrigger>
1920
+ <SelectContent>
1921
+ {resolutionStatuses.map((status) => (
1922
+ <SelectItem key={status} value={status}>
1923
+ {t(`warranty_claims.status.${status}`)}
1924
+ </SelectItem>
1925
+ ))}
1926
+ </SelectContent>
1927
+ </Select>
1928
+ {featureAccess.claimManage && primaryNextStatus ? (
1929
+ <Button
1930
+ type="button"
1931
+ size="sm"
1932
+ onClick={() => { void confirmAndTransition(primaryNextStatus) }}
1933
+ >
1934
+ {primaryNextStatus === 'submitted' && currentStatus === 'draft'
1935
+ ? t('warranty_claims.detail.actions.submit')
1936
+ : primaryNextStatus === 'in_review' && currentStatus === 'submitted'
1937
+ ? t('warranty_claims.list.actions.startReview')
1938
+ : t(`warranty_claims.status.${primaryNextStatus}`)}
1939
+ </Button>
1940
+ ) : null}
1941
+ </div>
1942
+ </div>
1943
+ <h1 className="mt-3 max-w-3xl text-2xl font-bold tracking-tight text-foreground">
1944
+ {claim.notes || claim.reasonCode || claim.claimNumber || t('warranty_claims.detail.unnumbered', 'Unnumbered claim')}
1945
+ </h1>
1946
+ <div className="mt-3 grid gap-4 sm:grid-cols-2 xl:max-w-4xl xl:grid-cols-6">
1947
+ <div>
1948
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.customer')}</p>
1949
+ <div className="mt-1 text-sm font-medium text-foreground">
1950
+ <span>
1951
+ {claim.customerName ? (
1952
+ customerLink && customerLink.customerId === claim.customerId ? (
1953
+ <Link
1954
+ href={customerLink.href}
1955
+ className="text-foreground underline-offset-4 hover:underline"
1956
+ >
1957
+ {claim.customerName}
1958
+ </Link>
1959
+ ) : (
1960
+ claim.customerName
1961
+ )
1962
+ ) : (
1963
+ noValue
1964
+ )}
1965
+ </span>
1966
+ </div>
1967
+ </div>
1968
+ <div>
1969
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.priority', 'Priority')}</p>
1970
+ <p className="mt-1 text-sm font-medium text-foreground">
1971
+ {t(`warranty_claims.priority.${claim.priority ?? 'normal'}`)}
1972
+ </p>
1973
+ </div>
1974
+ <div>
1975
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.opened')}</p>
1976
+ <p className="mt-1 text-sm font-medium text-foreground" title={formatDateTime(claim.createdAt) ?? undefined}>
1977
+ {formatRelativeTime(claim.createdAt, { locale }) ?? noValue}
1978
+ </p>
1979
+ </div>
1980
+ <div>
1981
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.list.column.slaDueAt')}</p>
1982
+ <div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
1983
+ <ClaimSlaIndicator
1984
+ slaDueAt={claim.slaDueAt}
1985
+ slaPausedAt={claim.slaPausedAt}
1986
+ submittedAt={claim.submittedAt}
1987
+ status={claim.status}
1988
+ atRiskThresholdPct={slaAtRiskThresholdPct}
1989
+ />
1990
+ </div>
1991
+ </div>
1992
+ <div>
1993
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.owner')}</p>
1994
+ <p className="mt-1 truncate text-sm font-medium text-foreground" title={assigneeDisplayName}>{assigneeDisplayName}</p>
1995
+ </div>
1996
+ <div>
1997
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.state')}</p>
1998
+ <div className="mt-1">
1999
+ <ClaimStatusBadge status={claim.status} />
2000
+ </div>
2001
+ </div>
2002
+ </div>
2003
+ {riskSignals.length > 0 ? (
2004
+ <div className="mt-3 flex flex-wrap items-center gap-2 xl:max-w-4xl" data-testid="warranty-claim-risk-signals">
2005
+ <RiskSignalChips signals={riskSignals} t={t} />
2006
+ </div>
2007
+ ) : null}
2008
+ </div>
2009
+ <ClaimStageProgress status={claim.status} />
2010
+ <Tabs
2011
+ value={activeTab}
2012
+ onValueChange={(value) => setActiveTab(value as TabId)}
2013
+ variant="underline"
2014
+ >
2015
+ <TabsList className="h-auto w-full flex-wrap gap-1 px-7" aria-label={t('warranty_claims.detail.tabs.label', 'Claim sections')}>
2016
+ {tabs.map((tab) => (
2017
+ <TabsTrigger
2018
+ key={tab.id}
2019
+ value={tab.id}
2020
+ count={tab.count}
2021
+ data-tab-id={tab.id}
2022
+ className="px-4 pb-2 pt-3"
2023
+ >
2024
+ {tab.label}
2025
+ </TabsTrigger>
2026
+ ))}
2027
+ </TabsList>
2028
+
2029
+ <TabsContent value="lines" className="mt-0">
2030
+ <div>
2031
+ <div className="grid items-center gap-4 border-b border-border px-8 py-5 sm:grid-cols-2 xl:grid-cols-[repeat(3,minmax(0,1fr))_auto]">
2032
+ <div>
2033
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.totalClaimed')}</p>
2034
+ <p className="mt-1 text-base font-semibold text-foreground">
2035
+ {formatAmount(claim.totalClaimedAmount, claim.currencyCode, noValue)}
2036
+ </p>
2037
+ <p className="mt-0.5 text-xs text-muted-foreground">
2038
+ {t('warranty_claims.detail.lines.count', { count: lines.length })}
2039
+ </p>
2040
+ </div>
2041
+ <div className="xl:border-l xl:border-border xl:pl-5">
2042
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.totalApproved')}</p>
2043
+ <p className="mt-1 text-base font-semibold text-foreground">
2044
+ {formatAmount(claim.totalApprovedAmount, claim.currencyCode, noValue)}
2045
+ </p>
2046
+ <p className="mt-0.5 text-xs text-muted-foreground">
2047
+ {t('warranty_claims.detail.lines.approvedCount', { count: approvedLineCount })}
2048
+ </p>
2049
+ </div>
2050
+ <div className="xl:border-l xl:border-border xl:pl-5">
2051
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.totalRecovered')}</p>
2052
+ <p className="mt-1 text-base font-semibold text-foreground">
2053
+ {formatAmount(claim.totalRecoveredAmount, claim.currencyCode, noValue)}
2054
+ </p>
2055
+ <p className="mt-0.5 text-xs text-muted-foreground">
2056
+ {claim.vendorName ?? t('warranty_claims.detail.lines.noVendorClaims')}
2057
+ </p>
2058
+ </div>
2059
+ {linesInlineEditable ? (
2060
+ <Button type="button" variant="outline" size="sm" className="justify-self-start xl:justify-self-end" onClick={() => setLineDialog({ mode: 'create' })}>
2061
+ {t('warranty_claims.detail.lines.add')}
2062
+ </Button>
2063
+ ) : null}
2064
+ </div>
2065
+ <div className="pb-3">
2066
+ <DataTable<ClaimLine>
2067
+ embedded
2068
+ columns={lineColumns}
2069
+ data={lines}
2070
+ stickyActionsColumn
2071
+ emptyState={(
2072
+ <EmptyState
2073
+ title={t('warranty_claims.detail.lines.empty.title')}
2074
+ description={t('warranty_claims.detail.lines.empty.description')}
2075
+ variant="subtle"
2076
+ />
2077
+ )}
2078
+ rowActions={linesInlineEditable ? (line) => (
2079
+ <RowActions
2080
+ items={[
2081
+ {
2082
+ id: 'edit',
2083
+ label: t('warranty_claims.detail.lines.edit'),
2084
+ onSelect: () => setLineDialog({ mode: 'edit', line }),
2085
+ },
2086
+ ]}
2087
+ />
2088
+ ) : undefined}
2089
+ />
2090
+ </div>
2091
+ <div className="px-8 pb-8">
2092
+ <ReceivingPanel
2093
+ claimId={claim.id}
2094
+ lines={lines}
2095
+ canManage={featureAccess.receivingManage}
2096
+ receivingCapable={RECEIVING_CAPABLE_CLAIM_STATUSES.has(currentStatus)}
2097
+ onRefresh={loadData}
2098
+ />
2099
+ </div>
2100
+ </div>
2101
+ </TabsContent>
2102
+
2103
+ <TabsContent value="timeline" className="mt-0">
2104
+ <div className="mx-8 max-w-3xl space-y-5 pb-8 pt-6">
2105
+ {featureAccess.claimManage ? (
2106
+ <div>
2107
+ <div className="space-y-4">
2108
+ <Textarea
2109
+ value={commentBody}
2110
+ onChange={(event) => setCommentBody(event.target.value)}
2111
+ onKeyDown={(event) => {
2112
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
2113
+ event.preventDefault()
2114
+ void submitComment()
2115
+ }
2116
+ }}
2117
+ placeholder={t('warranty_claims.detail.timeline.commentPlaceholder')}
2118
+ rows={6}
2119
+ />
2120
+ <div className="flex flex-wrap items-center justify-between gap-2">
2121
+ <div className="flex flex-wrap items-center gap-2">
2122
+ <Select value={commentVisibility} onValueChange={(next) => setCommentVisibility(next === 'customer' ? 'customer' : 'internal')}>
2123
+ <SelectTrigger className="w-40" aria-label={t('warranty_claims.detail.visibility.label')}>
2124
+ <SelectValue />
2125
+ </SelectTrigger>
2126
+ <SelectContent>
2127
+ <SelectItem value="internal">{t('warranty_claims.detail.visibility.internal')}</SelectItem>
2128
+ <SelectItem value="customer">{t('warranty_claims.detail.visibility.customer')}</SelectItem>
2129
+ </SelectContent>
2130
+ </Select>
2131
+ {!draftReplyHidden && featureAccess.claimManage && claimMutable ? (
2132
+ <Button
2133
+ type="button"
2134
+ variant="outline"
2135
+ onClick={() => { void draftReplyWithAi() }}
2136
+ disabled={draftReplyLoading}
2137
+ >
2138
+ <AiIcon className="size-4" aria-hidden="true" />
2139
+ {draftReplyLoading
2140
+ ? t('warranty_claims.ai.draftingReply')
2141
+ : t('warranty_claims.ai.draftReply')}
2142
+ </Button>
2143
+ ) : null}
2144
+ </div>
2145
+ <Button type="button" onClick={() => { void submitComment() }} disabled={!commentBody.trim()}>
2146
+ {t('warranty_claims.detail.timeline.addComment')}
2147
+ </Button>
2148
+ </div>
2149
+ </div>
2150
+ </div>
2151
+ ) : null}
2152
+ <div className="space-y-3">
2153
+ {events.length ? (
2154
+ <SegmentedControl
2155
+ size="sm"
2156
+ value={timelineFilter}
2157
+ onValueChange={(next) => setTimelineFilter(isTimelineFilterId(next) ? next : 'all')}
2158
+ aria-label={t('warranty_claims.detail.timeline.filter.label', 'Filter timeline')}
2159
+ >
2160
+ {TIMELINE_FILTERS.map((filter) => (
2161
+ <SegmentedControlItem key={filter} value={filter}>
2162
+ {t(TIMELINE_FILTER_LABELS[filter].key, TIMELINE_FILTER_LABELS[filter].fallback)}
2163
+ </SegmentedControlItem>
2164
+ ))}
2165
+ </SegmentedControl>
2166
+ ) : null}
2167
+ {filteredTimelineEvents.length ? filteredTimelineEvents.map((event) => {
2168
+ const Icon = eventIcon(event.kind)
2169
+ const body = formatTimelineBody(event, t, userNames)
2170
+ const actor = resolveTimelineActor(event, claim, userNames, t)
2171
+ return (
2172
+ <div key={event.id} className="flex gap-3 py-2">
2173
+ <div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
2174
+ <Icon className="h-4 w-4" aria-hidden />
2175
+ </div>
2176
+ <div className="min-w-0 flex-1 space-y-1">
2177
+ <div className="flex flex-wrap items-center gap-2">
2178
+ <span className="text-sm font-medium">{t(`warranty_claims.eventKind.${event.kind}`)}</span>
2179
+ <span className="text-sm text-muted-foreground">{actor}</span>
2180
+ <StatusBadge variant={event.visibility === 'customer' ? 'info' : 'neutral'}>
2181
+ {event.visibility === 'customer'
2182
+ ? t('warranty_claims.detail.visibility.customer')
2183
+ : t('warranty_claims.detail.visibility.internal')}
2184
+ </StatusBadge>
2185
+ <span className="text-xs text-muted-foreground">{formatDateTime(event.createdAt) ?? noValue}</span>
2186
+ </div>
2187
+ {body ? <p className="text-sm">{body}</p> : null}
2188
+ </div>
2189
+ </div>
2190
+ )
2191
+ }) : events.length ? (
2192
+ <EmptyState
2193
+ title={t('warranty_claims.detail.timeline.filterEmpty.title', 'No matching events')}
2194
+ description={t('warranty_claims.detail.timeline.filterEmpty.description', 'No timeline events match the selected filter.')}
2195
+ variant="subtle"
2196
+ />
2197
+ ) : (
2198
+ <EmptyState
2199
+ title={t('warranty_claims.detail.timeline.empty.title')}
2200
+ description={t('warranty_claims.detail.timeline.empty.description')}
2201
+ variant="subtle"
2202
+ />
2203
+ )}
2204
+ </div>
2205
+ </div>
2206
+ </TabsContent>
2207
+
2208
+ <TabsContent value="attachments" className="mt-0">
2209
+ <div className="px-8 pb-8 pt-6">
2210
+ <AttachmentInput
2211
+ entityId="warranty_claims:warranty_claim"
2212
+ recordId={claim.id}
2213
+ allowDelete={featureAccess.claimManage}
2214
+ allowReplace={featureAccess.claimManage}
2215
+ disabled={!featureAccess.claimManage}
2216
+ onCountChange={setAttachmentCount}
2217
+ renderItemMeta={(item) => (
2218
+ isCustomerVisibleAttachment(item.tags) ? (
2219
+ <StatusBadge variant="info">
2220
+ {t('warranty_claims.detail.attachments.customerVisible', 'Visible to customer')}
2221
+ </StatusBadge>
2222
+ ) : null
2223
+ )}
2224
+ />
2225
+ </div>
2226
+ </TabsContent>
2227
+
2228
+ <TabsContent value="ai" className="mt-0">
2229
+ <div className="grid gap-4 px-8 pb-8 pt-6 xl:grid-cols-2">
2230
+ <div className="rounded-lg border border-border bg-card p-4">
2231
+ <div className="flex items-start justify-between gap-3">
2232
+ <div className="min-w-0">
2233
+ <h3 className="text-sm font-medium">{t('warranty_claims.triage.panelTitle')}</h3>
2234
+ <p className="mt-1 text-sm text-muted-foreground">{t('warranty_claims.triage.panelSubtitle')}</p>
2235
+ </div>
2236
+ <Button type="button" variant="outline" onClick={loadAiSuggestion} disabled={aiLoading}>
2237
+ {t('warranty_claims.triage.suggestButton')}
2238
+ </Button>
2239
+ </div>
2240
+ {aiSuggestion ? (
2241
+ <div className="mt-4 space-y-4">
2242
+ <div className="rounded-md border border-border p-3">
2243
+ <div className="flex flex-wrap items-center gap-2">
2244
+ <StatusBadge variant={aiSuggestion.priority.overdue ? 'error' : 'info'}>
2245
+ {t(`warranty_claims.priority.${aiSuggestion.priority.currentPriority}`)}
2246
+ {' → '}
2247
+ {t(`warranty_claims.priority.${aiSuggestion.priority.suggestedPriority}`)}
2248
+ </StatusBadge>
2249
+ <RiskSignalChips signals={aiSuggestion.risk?.signals ?? []} t={t} />
2250
+ </div>
2251
+ <p className="mt-2 text-sm text-muted-foreground">{formatTriageMessage(aiSuggestion.priority.reason, t)}</p>
2252
+ </div>
2253
+ <div className="space-y-2">
2254
+ {aiSuggestion.lines.map((suggestedLine) => {
2255
+ const applied = appliedTriageLineIds.has(suggestedLine.lineId)
2256
+ return (
2257
+ <div
2258
+ key={suggestedLine.lineId}
2259
+ className="flex flex-wrap items-start justify-between gap-3 rounded-md border border-border p-3"
2260
+ >
2261
+ <div className="min-w-0">
2262
+ <div className="text-sm font-medium">
2263
+ {triageLineHeading(suggestedLine, lines, t)}
2264
+ </div>
2265
+ <div className="mt-1 flex flex-wrap items-center gap-2">
2266
+ <StatusBadge variant={eligibilityBadgeVariant(suggestedLine.eligibility.status)}>
2267
+ {t(`warranty_claims.eligibility.${suggestedLine.eligibility.status}`)}
2268
+ </StatusBadge>
2269
+ <StatusBadge variant="info">
2270
+ {t(`warranty_claims.disposition.${suggestedLine.suggestedDisposition}`)}
2271
+ </StatusBadge>
2272
+ </div>
2273
+ <p className="mt-2 text-sm text-muted-foreground">{formatTriageMessage(suggestedLine.reason, t)}</p>
2274
+ <p className="mt-1 text-xs text-muted-foreground">{formatTriageMessage(suggestedLine.eligibility.reason, t)}</p>
2275
+ </div>
2276
+ {applied ? (
2277
+ <StatusBadge variant="success">
2278
+ {t('warranty_claims.triage.applied', 'Applied')}
2279
+ {': '}
2280
+ {t(`warranty_claims.disposition.${suggestedLine.suggestedDisposition}`)}
2281
+ </StatusBadge>
2282
+ ) : (
2283
+ <Button
2284
+ type="button"
2285
+ variant="outline"
2286
+ size="sm"
2287
+ onClick={() => applyAiDisposition(suggestedLine.lineId, suggestedLine.suggestedDisposition)}
2288
+ >
2289
+ {t('warranty_claims.triage.apply')}
2290
+ </Button>
2291
+ )}
2292
+ </div>
2293
+ )
2294
+ })}
2295
+ </div>
2296
+ </div>
2297
+ ) : (
2298
+ <div className="mt-4">
2299
+ <EmptyState
2300
+ title={t('warranty_claims.triage.panelTitle')}
2301
+ description={t('warranty_claims.triage.empty.description')}
2302
+ variant="subtle"
2303
+ />
2304
+ </div>
2305
+ )}
2306
+ </div>
2307
+ <div className="h-96 overflow-hidden rounded-lg border border-border bg-card">
2308
+ <React.Suspense fallback={<LoadingMessage label={t('warranty_claims.detail.loading')} />}>
2309
+ {/* Scope the conversation to this claim and remount on claim change so a
2310
+ claim can never inherit another claim's transcript (WQA-007 / AI-01). */}
2311
+ <LazyAiChat
2312
+ key={`${AGENT_ID}:${claim.id}`}
2313
+ agent={AGENT_ID}
2314
+ pageContext={{ entityType: 'warranty_claims.claim', recordId: claim.id }}
2315
+ className="h-full"
2316
+ placeholder={t('warranty_claims.ai.chatPlaceholder')}
2317
+ welcomeTitle={t('warranty_claims.ai.chatWelcomeTitle')}
2318
+ />
2319
+ </React.Suspense>
2320
+ </div>
2321
+ {lines.length ? (
2322
+ <div className="space-y-3 xl:col-span-2">
2323
+ {lines.map((line) => (
2324
+ <div key={line.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-4">
2325
+ <div className="min-w-0">
2326
+ <p className="truncate text-sm font-medium text-foreground">
2327
+ {line.productName ?? line.sku ?? t('warranty_claims.detail.lines.unnamed', 'Unnamed line')}
2328
+ </p>
2329
+ <p className="mt-1 text-xs text-muted-foreground">
2330
+ {[line.sku, line.serialNumber].filter(Boolean).join(' / ') || noValue}
2331
+ </p>
2332
+ </div>
2333
+ <AiAssessButtons
2334
+ claimId={claim.id}
2335
+ line={line}
2336
+ canManage={featureAccess.claimManage && claimMutable}
2337
+ onRefresh={loadData}
2338
+ />
2339
+ </div>
2340
+ ))}
2341
+ </div>
2342
+ ) : null}
2343
+ </div>
2344
+ </TabsContent>
2345
+ </Tabs>
2346
+ </div>
2347
+
2348
+ <ReturnLabelPanel claim={claim} canManage={featureAccess.claimManage} onRefresh={loadData} />
2349
+
2350
+ {(() => {
2351
+ const showSalesReturn = featureAccess.claimManage
2352
+ && featureAccess.salesReturnsCreate
2353
+ && claim.orderId
2354
+ && !claim.salesReturnId
2355
+ && ['approved', 'awaiting_return', 'received', 'inspecting'].includes(currentStatus ?? '')
2356
+ const showReplacementOrder = featureAccess.claimManage
2357
+ && featureAccess.salesOrdersManage
2358
+ && claim.orderId
2359
+ && !claim.replacementOrderId
2360
+ && ['approved', 'awaiting_return', 'received', 'inspecting', 'resolved'].includes(currentStatus ?? '')
2361
+ const showCreditMemo = featureAccess.claimManage
2362
+ && featureAccess.salesCreditMemosManage
2363
+ && claim.orderId
2364
+ && !claim.creditMemoId
2365
+ && ['received', 'inspecting', 'resolved'].includes(currentStatus ?? '')
2366
+ if (!showSalesReturn && !showReplacementOrder && !showCreditMemo) return null
2367
+ return (
2368
+ <div className="flex flex-wrap items-center gap-2">
2369
+ {showSalesReturn ? (
2370
+ <Button type="button" variant="outline" onClick={() => { void createSalesReturn() }}>
2371
+ {t('warranty_claims.detail.createSalesReturn')}
2372
+ </Button>
2373
+ ) : null}
2374
+ {showReplacementOrder ? (
2375
+ <Button type="button" variant="outline" onClick={() => { void createReplacementOrder() }}>
2376
+ {t('warranty_claims.detail.createReplacementOrder')}
2377
+ </Button>
2378
+ ) : null}
2379
+ {showCreditMemo ? (
2380
+ <Button type="button" variant="outline" onClick={() => { void createCreditMemo() }}>
2381
+ {t('warranty_claims.detail.createCreditMemo')}
2382
+ </Button>
2383
+ ) : null}
2384
+ </div>
2385
+ )
2386
+ })()}
2387
+
2388
+ <VendorRecoverySuggestionsPanel
2389
+ claim={claim}
2390
+ canManage={featureAccess.claimManage}
2391
+ onGenerateSupplierRecovery={generateSupplierRecovery}
2392
+ />
2393
+ </div>
2394
+ </PageBody>
2395
+
2396
+ <Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
2397
+ <DialogContent
2398
+ className="max-w-lg"
2399
+ onKeyDown={(event) => {
2400
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
2401
+ event.preventDefault()
2402
+ void assignClaim()
2403
+ }
2404
+ if (event.key === 'Escape') {
2405
+ setAssignDialogOpen(false)
2406
+ }
2407
+ }}
2408
+ >
2409
+ <DialogHeader>
2410
+ <DialogTitle>{t('warranty_claims.detail.assignDialog.title')}</DialogTitle>
2411
+ </DialogHeader>
2412
+ <div className="space-y-4">
2413
+ <div className="space-y-2">
2414
+ <Label htmlFor="warranty-claim-assignee-search">
2415
+ {t('warranty_claims.detail.assignDialog.search')}
2416
+ </Label>
2417
+ <Input
2418
+ id="warranty-claim-assignee-search"
2419
+ value={assignSearch}
2420
+ onChange={(event) => setAssignSearch(event.target.value)}
2421
+ placeholder={t('warranty_claims.detail.assignDialog.searchPlaceholder')}
2422
+ />
2423
+ </div>
2424
+ <div className="space-y-2">
2425
+ <Label htmlFor="warranty-claim-assignee-select">
2426
+ {t('warranty_claims.detail.assignDialog.staff')}
2427
+ </Label>
2428
+ <Select value={selectedAssigneeUserId} onValueChange={setSelectedAssigneeUserId}>
2429
+ <SelectTrigger id="warranty-claim-assignee-select">
2430
+ <SelectValue />
2431
+ </SelectTrigger>
2432
+ <SelectContent>
2433
+ <SelectItem value={UNASSIGNED_SELECT_VALUE}>{t('warranty_claims.detail.unassigned')}</SelectItem>
2434
+ {assignableOptions.map((staffMember) => (
2435
+ <SelectItem key={staffMember.userId} value={staffMember.userId}>
2436
+ {staffMember.email && staffMember.email !== staffMember.displayName
2437
+ ? `${staffMember.displayName} (${staffMember.email})`
2438
+ : staffMember.displayName}
2439
+ </SelectItem>
2440
+ ))}
2441
+ </SelectContent>
2442
+ </Select>
2443
+ {assignLoading ? (
2444
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.assignDialog.loading')}</p>
2445
+ ) : null}
2446
+ {!assignLoading && assignableOptions.length === 0 ? (
2447
+ <p className="text-xs text-muted-foreground">{t('warranty_claims.detail.assignDialog.empty')}</p>
2448
+ ) : null}
2449
+ </div>
2450
+ </div>
2451
+ <DialogFooter>
2452
+ <Button type="button" variant="outline" onClick={() => setAssignDialogOpen(false)}>
2453
+ {t('common.cancel', 'Cancel')}
2454
+ </Button>
2455
+ <Button type="button" onClick={() => { void assignClaim() }}>
2456
+ {t('warranty_claims.form.submit')}
2457
+ </Button>
2458
+ </DialogFooter>
2459
+ </DialogContent>
2460
+ </Dialog>
2461
+
2462
+ <Dialog open={lineDialog !== null} onOpenChange={(open) => { if (!open) setLineDialog(null) }}>
2463
+ <DialogContent className="sm:max-w-3xl">
2464
+ <DialogHeader>
2465
+ <DialogTitle>
2466
+ {lineDialog?.mode === 'edit'
2467
+ ? t('warranty_claims.detail.lines.edit')
2468
+ : t('warranty_claims.detail.lines.add')}
2469
+ </DialogTitle>
2470
+ </DialogHeader>
2471
+ <CrudForm<ClaimLineFormValues>
2472
+ embedded
2473
+ title={lineDialog?.mode === 'edit' ? t('warranty_claims.detail.lines.edit') : t('warranty_claims.detail.lines.add')}
2474
+ fields={lineDialog?.mode === 'edit' ? lineFields : lineFields.filter((field) => field.id !== 'lineStatus')}
2475
+ groups={lineDialog?.mode === 'edit'
2476
+ ? lineGroups
2477
+ : lineGroups.map((group) => ({ ...group, fields: (group.fields ?? []).filter((fieldId) => fieldId !== 'lineStatus') }))}
2478
+ initialValues={lineDialog?.mode === 'edit'
2479
+ ? {
2480
+ ...lineDialog.line,
2481
+ qtyClaimed: quantityInputValue(lineDialog.line.qtyClaimed),
2482
+ qtyApproved: quantityInputValue(lineDialog.line.qtyApproved),
2483
+ qtyReceived: quantityInputValue(lineDialog.line.qtyReceived),
2484
+ }
2485
+ : { claimId: claim.id, qtyClaimed: '1', lineStatus: 'pending', warrantyMonths: defaultWarrantyMonths ?? undefined }}
2486
+ submitLabel={t('warranty_claims.form.submit')}
2487
+ onSubmit={async (values) => {
2488
+ const payload: Record<string, unknown> = {
2489
+ claimId: claim.id,
2490
+ productId: nullableText(values.productId),
2491
+ variantId: nullableText(values.variantId),
2492
+ productName: nullableText(values.productName),
2493
+ sku: nullableText(values.sku),
2494
+ serialNumber: nullableText(values.serialNumber),
2495
+ purchaseDate: nullableText(values.purchaseDate),
2496
+ warrantyMonths: values.warrantyMonths === null || values.warrantyMonths === undefined || String(values.warrantyMonths).trim() === ''
2497
+ ? null
2498
+ : Number(values.warrantyMonths),
2499
+ faultCode: nullableText(values.faultCode),
2500
+ faultDescription: nullableText(values.faultDescription),
2501
+ qtyClaimed: values.qtyClaimed ?? 1,
2502
+ qtyApproved: values.qtyApproved ?? null,
2503
+ qtyReceived: values.qtyReceived ?? null,
2504
+ disposition: nullableText(values.disposition),
2505
+ creditAmount: values.creditAmount ?? null,
2506
+ restockingFee: values.restockingFee ?? null,
2507
+ coreChargeAmount: values.coreChargeAmount ?? null,
2508
+ coreCreditAmount: values.coreCreditAmount ?? null,
2509
+ conditionOnReceipt: nullableText(values.conditionOnReceipt),
2510
+ inspectionNotes: nullableText(values.inspectionNotes),
2511
+ }
2512
+ if (lineDialog?.mode === 'edit') {
2513
+ payload.lineStatus = nullableText(values.lineStatus) ?? 'pending'
2514
+ }
2515
+ if (lineDialog?.mode === 'edit') {
2516
+ await updateCrud('warranty_claims/lines', { id: lineDialog.line.id, ...payload }, {
2517
+ errorMessage: t('warranty_claims.detail.lines.error.save'),
2518
+ })
2519
+ flash(t('warranty_claims.detail.lines.flash.saved'), 'success')
2520
+ } else {
2521
+ await createCrud('warranty_claims/lines', payload, {
2522
+ errorMessage: t('warranty_claims.detail.lines.error.save'),
2523
+ })
2524
+ flash(t('warranty_claims.detail.lines.flash.created'), 'success')
2525
+ }
2526
+ setLineDialog(null)
2527
+ await loadData()
2528
+ }}
2529
+ onDelete={lineDialog?.mode === 'edit'
2530
+ ? async () => {
2531
+ await deleteCrud('warranty_claims/lines', {
2532
+ body: { id: lineDialog.line.id, claimId: claim.id },
2533
+ errorMessage: t('warranty_claims.detail.lines.error.delete'),
2534
+ })
2535
+ flash(t('warranty_claims.detail.lines.flash.deleted'), 'success')
2536
+ setLineDialog(null)
2537
+ await loadData()
2538
+ }
2539
+ : undefined}
2540
+ />
2541
+ </DialogContent>
2542
+ </Dialog>
2543
+
2544
+ <Dialog open={transitionDialog !== null} onOpenChange={(open) => { if (!open) setTransitionDialog(null) }}>
2545
+ <DialogContent className="max-w-lg">
2546
+ <DialogHeader>
2547
+ <DialogTitle>{t('warranty_claims.detail.confirm.rejectTitle')}</DialogTitle>
2548
+ </DialogHeader>
2549
+ <CrudForm<TransitionFormValues>
2550
+ embedded
2551
+ title={t('warranty_claims.detail.confirm.rejectTitle')}
2552
+ fields={transitionFields}
2553
+ initialValues={{ rejectionReasonCode: claim.rejectionReasonCode ?? '', resolutionSummary: claim.resolutionSummary ?? '' }}
2554
+ submitLabel={t('warranty_claims.detail.actions.transition')}
2555
+ onSubmit={async (values) => {
2556
+ if (!transitionDialog) return
2557
+ await handleTransition(transitionDialog.toStatus, values)
2558
+ setTransitionDialog(null)
2559
+ }}
2560
+ />
2561
+ </DialogContent>
2562
+ </Dialog>
2563
+
2564
+ <Dialog open={vendorDialogOpen} onOpenChange={setVendorDialogOpen}>
2565
+ <DialogContent className="max-w-lg">
2566
+ <DialogHeader>
2567
+ <DialogTitle>{t('warranty_claims.detail.actions.vendorRecovery')}</DialogTitle>
2568
+ </DialogHeader>
2569
+ <CrudForm<VendorRecoveryFormValues>
2570
+ embedded
2571
+ title={t('warranty_claims.detail.actions.vendorRecovery')}
2572
+ fields={vendorFields}
2573
+ initialValues={{ lineIds: eligibleVendorLines.map((line) => line.id), vendorName: '', vendorRef: '' }}
2574
+ submitLabel={t('warranty_claims.detail.actions.vendorRecovery')}
2575
+ onSubmit={async (values) => {
2576
+ const lineIds = Array.isArray(values.lineIds) ? values.lineIds.filter((lineId): lineId is string => typeof lineId === 'string') : []
2577
+ if (!lineIds.length) {
2578
+ flash(t('warranty_claims.errors.vendorRecoveryNeedsResolvedLines'), 'error')
2579
+ return
2580
+ }
2581
+ await runClaimPost(
2582
+ '/api/warranty_claims/vendor-recovery',
2583
+ {
2584
+ claimId: claim.id,
2585
+ lineIds,
2586
+ vendorName: nullableText(values.vendorName),
2587
+ vendorRef: nullableText(values.vendorRef),
2588
+ },
2589
+ 'warranty_claims.detail.vendorRecovery.created',
2590
+ )
2591
+ setVendorDialogOpen(false)
2592
+ }}
2593
+ />
2594
+ </DialogContent>
2595
+ </Dialog>
2596
+ {ConfirmDialogElement}
2597
+ </Page>
2598
+ )
2599
+ }