adserver-dashboard 1.0.0

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 (525) hide show
  1. package/.ci/staging.yml +191 -0
  2. package/.dockerignore +117 -0
  3. package/.env +40 -0
  4. package/.env.staging +38 -0
  5. package/.gitlab-ci.yml +16 -0
  6. package/DEMO_STATUS.md +579 -0
  7. package/Dockerfile +61 -0
  8. package/Influence-MW-AdServer-12-02-2026/client/index.html +17 -0
  9. package/Influence-MW-AdServer-12-02-2026/client/public/favicon.png +0 -0
  10. package/Influence-MW-AdServer-12-02-2026/client/src/App.tsx +91 -0
  11. package/Influence-MW-AdServer-12-02-2026/client/src/components/advanced-map-drawer.tsx +1131 -0
  12. package/Influence-MW-AdServer-12-02-2026/client/src/components/ai-recommendation-panel.tsx +379 -0
  13. package/Influence-MW-AdServer-12-02-2026/client/src/components/app-sidebar.tsx +183 -0
  14. package/Influence-MW-AdServer-12-02-2026/client/src/components/auto-optimize-button.tsx +184 -0
  15. package/Influence-MW-AdServer-12-02-2026/client/src/components/availability-drawer.tsx +385 -0
  16. package/Influence-MW-AdServer-12-02-2026/client/src/components/brand-insights-panel.tsx +87 -0
  17. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-agency-drawer.tsx +198 -0
  18. package/Influence-MW-AdServer-12-02-2026/client/src/components/create-brand-drawer.tsx +275 -0
  19. package/Influence-MW-AdServer-12-02-2026/client/src/components/creative-assignment.tsx +526 -0
  20. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table-toolbar.tsx +148 -0
  21. package/Influence-MW-AdServer-12-02-2026/client/src/components/data-table.tsx +158 -0
  22. package/Influence-MW-AdServer-12-02-2026/client/src/components/filter-drawer.tsx +356 -0
  23. package/Influence-MW-AdServer-12-02-2026/client/src/components/form-insights-panel.tsx +82 -0
  24. package/Influence-MW-AdServer-12-02-2026/client/src/components/geography-selector.tsx +699 -0
  25. package/Influence-MW-AdServer-12-02-2026/client/src/components/header-user-menu.tsx +178 -0
  26. package/Influence-MW-AdServer-12-02-2026/client/src/components/history-drawer.tsx +313 -0
  27. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-availability-section.tsx +176 -0
  28. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-format-drawer.tsx +173 -0
  29. package/Influence-MW-AdServer-12-02-2026/client/src/components/inventory-selector.tsx +401 -0
  30. package/Influence-MW-AdServer-12-02-2026/client/src/components/manual-inventory-drawer.tsx +368 -0
  31. package/Influence-MW-AdServer-12-02-2026/client/src/components/mapbox-map.tsx +368 -0
  32. package/Influence-MW-AdServer-12-02-2026/client/src/components/market-insights-panel.tsx +202 -0
  33. package/Influence-MW-AdServer-12-02-2026/client/src/components/media-owner-drawer.tsx +217 -0
  34. package/Influence-MW-AdServer-12-02-2026/client/src/components/metric-card.tsx +58 -0
  35. package/Influence-MW-AdServer-12-02-2026/client/src/components/page-header.tsx +27 -0
  36. package/Influence-MW-AdServer-12-02-2026/client/src/components/player-status-indicator.tsx +137 -0
  37. package/Influence-MW-AdServer-12-02-2026/client/src/components/poi-targeting-drawer.tsx +298 -0
  38. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommendation-score-badge.tsx +102 -0
  39. package/Influence-MW-AdServer-12-02-2026/client/src/components/recommended-inventories-panel.tsx +248 -0
  40. package/Influence-MW-AdServer-12-02-2026/client/src/components/searchable-combobox.tsx +134 -0
  41. package/Influence-MW-AdServer-12-02-2026/client/src/components/signal-visualizations.tsx +407 -0
  42. package/Influence-MW-AdServer-12-02-2026/client/src/components/status-badge.tsx +35 -0
  43. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-provider.tsx +73 -0
  44. package/Influence-MW-AdServer-12-02-2026/client/src/components/theme-toggle.tsx +37 -0
  45. package/Influence-MW-AdServer-12-02-2026/client/src/components/traffic-slider.tsx +75 -0
  46. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/accordion.tsx +56 -0
  47. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert-dialog.tsx +139 -0
  48. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/alert.tsx +59 -0
  49. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/aspect-ratio.tsx +5 -0
  50. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/avatar.tsx +51 -0
  51. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/badge.tsx +38 -0
  52. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/breadcrumb.tsx +115 -0
  53. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/button.tsx +62 -0
  54. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/calendar.tsx +68 -0
  55. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/card.tsx +85 -0
  56. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/carousel.tsx +260 -0
  57. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/chart.tsx +365 -0
  58. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/checkbox.tsx +28 -0
  59. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/collapsible.tsx +11 -0
  60. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/command.tsx +151 -0
  61. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/context-menu.tsx +198 -0
  62. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dialog.tsx +122 -0
  63. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/drawer.tsx +118 -0
  64. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/dropdown-menu.tsx +198 -0
  65. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/form.tsx +178 -0
  66. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/hover-card.tsx +29 -0
  67. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input-otp.tsx +69 -0
  68. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/input.tsx +23 -0
  69. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/label.tsx +24 -0
  70. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/menubar.tsx +256 -0
  71. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/navigation-menu.tsx +128 -0
  72. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/pagination.tsx +117 -0
  73. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/popover.tsx +29 -0
  74. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/progress.tsx +28 -0
  75. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/radio-group.tsx +42 -0
  76. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/resizable.tsx +45 -0
  77. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/scroll-area.tsx +46 -0
  78. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/select.tsx +160 -0
  79. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/separator.tsx +29 -0
  80. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sheet.tsx +140 -0
  81. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/sidebar.tsx +727 -0
  82. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/skeleton.tsx +15 -0
  83. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/slider.tsx +26 -0
  84. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/switch.tsx +27 -0
  85. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/table.tsx +117 -0
  86. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tabs.tsx +53 -0
  87. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/textarea.tsx +22 -0
  88. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toast.tsx +127 -0
  89. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toaster.tsx +33 -0
  90. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle-group.tsx +61 -0
  91. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/toggle.tsx +43 -0
  92. package/Influence-MW-AdServer-12-02-2026/client/src/components/ui/tooltip.tsx +30 -0
  93. package/Influence-MW-AdServer-12-02-2026/client/src/components/vendor-stores-modal.tsx +336 -0
  94. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-drawer.tsx +359 -0
  95. package/Influence-MW-AdServer-12-02-2026/client/src/components/venue-type-selector.tsx +436 -0
  96. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-mobile.tsx +19 -0
  97. package/Influence-MW-AdServer-12-02-2026/client/src/hooks/use-toast.ts +191 -0
  98. package/Influence-MW-AdServer-12-02-2026/client/src/index.css +244 -0
  99. package/Influence-MW-AdServer-12-02-2026/client/src/lib/queryClient.ts +57 -0
  100. package/Influence-MW-AdServer-12-02-2026/client/src/lib/utils.ts +39 -0
  101. package/Influence-MW-AdServer-12-02-2026/client/src/lib/venue-taxonomy.ts +532 -0
  102. package/Influence-MW-AdServer-12-02-2026/client/src/main.tsx +5 -0
  103. package/Influence-MW-AdServer-12-02-2026/client/src/pages/assign-creative.tsx +781 -0
  104. package/Influence-MW-AdServer-12-02-2026/client/src/pages/content-hub.tsx +995 -0
  105. package/Influence-MW-AdServer-12-02-2026/client/src/pages/custom-pois.tsx +431 -0
  106. package/Influence-MW-AdServer-12-02-2026/client/src/pages/dashboard.tsx +620 -0
  107. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-detail.tsx +1062 -0
  108. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deal-form.tsx +1570 -0
  109. package/Influence-MW-AdServer-12-02-2026/client/src/pages/deals.tsx +716 -0
  110. package/Influence-MW-AdServer-12-02-2026/client/src/pages/edit-creative-assignment.tsx +1051 -0
  111. package/Influence-MW-AdServer-12-02-2026/client/src/pages/geotargeting.tsx +675 -0
  112. package/Influence-MW-AdServer-12-02-2026/client/src/pages/integrations.tsx +425 -0
  113. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-creatives.tsx +622 -0
  114. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-item-form.tsx +3132 -0
  115. package/Influence-MW-AdServer-12-02-2026/client/src/pages/line-items.tsx +530 -0
  116. package/Influence-MW-AdServer-12-02-2026/client/src/pages/not-found.tsx +21 -0
  117. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play-upload.tsx +479 -0
  118. package/Influence-MW-AdServer-12-02-2026/client/src/pages/proof-of-play.tsx +880 -0
  119. package/Influence-MW-AdServer-12-02-2026/client/src/pages/reports.tsx +235 -0
  120. package/Influence-MW-AdServer-12-02-2026/client/src/pages/settings.tsx +652 -0
  121. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signal-form.tsx +1117 -0
  122. package/Influence-MW-AdServer-12-02-2026/client/src/pages/signals.tsx +366 -0
  123. package/Influence-MW-AdServer-12-02-2026/client/src/pages/tags.tsx +332 -0
  124. package/Influence-MW-AdServer-12-02-2026/client/src/pages/venues.tsx +381 -0
  125. package/Influence-MW-AdServer-12-02-2026/client/src/types/mapbox-gl-draw.d.ts +37 -0
  126. package/Influence-MW-AdServer-12-02-2026/client/src/types/react-simple-maps.d.ts +57 -0
  127. package/Influence-MW-AdServer-12-02-2026/components.json +20 -0
  128. package/Influence-MW-AdServer-12-02-2026/docs/PRD.md +3373 -0
  129. package/Influence-MW-AdServer-12-02-2026/docs/influence-feature-mapping.csv +498 -0
  130. package/Influence-MW-AdServer-12-02-2026/drizzle.config.ts +14 -0
  131. package/Influence-MW-AdServer-12-02-2026/package-lock.json +9672 -0
  132. package/Influence-MW-AdServer-12-02-2026/package.json +118 -0
  133. package/Influence-MW-AdServer-12-02-2026/postcss.config.js +6 -0
  134. package/Influence-MW-AdServer-12-02-2026/replit.md +91 -0
  135. package/Influence-MW-AdServer-12-02-2026/script/build.ts +67 -0
  136. package/Influence-MW-AdServer-12-02-2026/scripts/create-miro-diagrams.cjs +318 -0
  137. package/Influence-MW-AdServer-12-02-2026/scripts/create-remaining-diagrams.cjs +270 -0
  138. package/Influence-MW-AdServer-12-02-2026/server/index.ts +103 -0
  139. package/Influence-MW-AdServer-12-02-2026/server/recommendation-service.ts +319 -0
  140. package/Influence-MW-AdServer-12-02-2026/server/routes.ts +1890 -0
  141. package/Influence-MW-AdServer-12-02-2026/server/static.ts +19 -0
  142. package/Influence-MW-AdServer-12-02-2026/server/storage.ts +2058 -0
  143. package/Influence-MW-AdServer-12-02-2026/server/vite.ts +58 -0
  144. package/Influence-MW-AdServer-12-02-2026/shared/schema.ts +1595 -0
  145. package/Influence-MW-AdServer-12-02-2026/tailwind.config.ts +107 -0
  146. package/Influence-MW-AdServer-12-02-2026/tsconfig.json +23 -0
  147. package/Influence-MW-AdServer-12-02-2026/vite.config.ts +40 -0
  148. package/LINE_ITEM_BUDGET_FIELD_MAPPING.md +178 -0
  149. package/PCM/.env.example +92 -0
  150. package/PCM/README.md +558 -0
  151. package/PCM/docs/TEST_CASES.md +422 -0
  152. package/PCM/index.js +106 -0
  153. package/PCM/package-lock.json +3282 -0
  154. package/PCM/package.json +32 -0
  155. package/PCM/replit.md +64 -0
  156. package/PCM/schema.sql +495 -0
  157. package/PCM/scripts/export-schema.js +183 -0
  158. package/PCM/scripts/seed-comprehensive.js +631 -0
  159. package/PCM/scripts/seed-production.js +477 -0
  160. package/PCM/src/config/db.js +56 -0
  161. package/PCM/src/config/swagger.js +5975 -0
  162. package/PCM/src/dto/EmailRequestDTO.js +166 -0
  163. package/PCM/src/middleware/errorHandler.js +52 -0
  164. package/PCM/src/middleware/logger.js +26 -0
  165. package/PCM/src/migrations/001_add_campaign_mode_fields.sql +36 -0
  166. package/PCM/src/migrations/002_create_deal_id_counters.sql +22 -0
  167. package/PCM/src/migrations/003_update_publishers_column.sql +15 -0
  168. package/PCM/src/migrations/004_add_direct_dealtype_and_advertiser.sql +5 -0
  169. package/PCM/src/migrations/005_add_programmatic_fields_and_update_enums.sql +31 -0
  170. package/PCM/src/migrations/006_add_line_item_programmatic_fields.sql +12 -0
  171. package/PCM/src/migrations/007_add_line_item_direct_fields.sql +15 -0
  172. package/PCM/src/migrations/008_add_inventory_fields.sql +45 -0
  173. package/PCM/src/migrations/009_move_inventory_fields_to_metadata.sql +32 -0
  174. package/PCM/src/migrations/010_add_draft_status_and_line_items_count.sql +23 -0
  175. package/PCM/src/migrations/011_add_planning_field.sql +21 -0
  176. package/PCM/src/migrations/012_fix_inventory_composite_pk.sql +17 -0
  177. package/PCM/src/migrations/013_make_external_id_optional.sql +3 -0
  178. package/PCM/src/migrations/014_create_change_history.sql +38 -0
  179. package/PCM/src/migrations/016_create_publisher_insertion_orders.sql +33 -0
  180. package/PCM/src/migrations/017_fix_line_item_id_fk_reference.sql +86 -0
  181. package/PCM/src/migrations/018_create_approval_tables.sql +44 -0
  182. package/PCM/src/migrations/019_add_encrypted_token_column.sql +2 -0
  183. package/PCM/src/migrations/020_add_rejection_reason_to_deals.sql +10 -0
  184. package/PCM/src/migrations/021_add_publisher_external_id_to_inventories.sql +12 -0
  185. package/PCM/src/migrations/022_add_line_item_extended_fields.sql +24 -0
  186. package/PCM/src/migrations/023_add_base_price_fields.sql +8 -0
  187. package/PCM/src/migrations/run-migrations.js +46 -0
  188. package/PCM/src/models/ApprovalOTP.js +51 -0
  189. package/PCM/src/models/ApprovalToken.js +79 -0
  190. package/PCM/src/models/ChangeHistory.js +107 -0
  191. package/PCM/src/models/Deal.js +186 -0
  192. package/PCM/src/models/DealIdCounter.js +28 -0
  193. package/PCM/src/models/LineItem.js +227 -0
  194. package/PCM/src/models/LineItemCreative.js +89 -0
  195. package/PCM/src/models/LineItemInventory.js +115 -0
  196. package/PCM/src/models/PublisherInsertionOrder.js +93 -0
  197. package/PCM/src/models/TransactionHistory.js +34 -0
  198. package/PCM/src/models/associations.js +81 -0
  199. package/PCM/src/routes/approval.js +321 -0
  200. package/PCM/src/routes/creatives.js +437 -0
  201. package/PCM/src/routes/deals.js +1638 -0
  202. package/PCM/src/routes/digitalSignage.js +242 -0
  203. package/PCM/src/routes/insertionOrders.js +380 -0
  204. package/PCM/src/routes/lineItems.js +926 -0
  205. package/PCM/src/routes/system.js +384 -0
  206. package/PCM/src/services/ApprovalService.js +885 -0
  207. package/PCM/src/services/CampaignImportConverter.js +631 -0
  208. package/PCM/src/services/CampaignModeService.js +273 -0
  209. package/PCM/src/services/CampaignStatusService.js +395 -0
  210. package/PCM/src/services/ChangeHistoryService.js +316 -0
  211. package/PCM/src/services/DealIdService.js +94 -0
  212. package/PCM/src/services/DealResponseFormatter.js +90 -0
  213. package/PCM/src/services/EmailNotificationService.js +315 -0
  214. package/PCM/src/services/LineItemResponseFormatter.js +122 -0
  215. package/PCM/src/services/LineItemStatusService.js +380 -0
  216. package/PCM/src/tests/comprehensiveTestRunner.js +360 -0
  217. package/PCM/src/tests/comprehensiveTests.js +1277 -0
  218. package/PCM/src/tests/dealTypeUnitTests.js +1058 -0
  219. package/PCM/src/tests/testRunner.js +248 -0
  220. package/PCM/src/utils/caseConverter.js +92 -0
  221. package/PCM/src/utils/dealCalculations.js +206 -0
  222. package/PCM/src/utils/lineItemPayloadNormalizer.js +41 -0
  223. package/PCM/src/utils/payloadNormalizer.js +34 -0
  224. package/PCM/src/utils/sourceNormalizer.js +56 -0
  225. package/PCM/src/validators/creativeValidator.js +27 -0
  226. package/PCM/src/validators/dealValidator.js +203 -0
  227. package/PCM/src/validators/lineItemValidator.js +489 -0
  228. package/PCM/tests/approval-flows.test.js +238 -0
  229. package/PCM/tests/approval-workflow.test.js +291 -0
  230. package/PCM/tests/campaign-import-converter.test.js +543 -0
  231. package/PCM/tests/campaign-import-e2e.test.js +520 -0
  232. package/PCM/tests/campaign-status.test.js +539 -0
  233. package/PCM/tests/direct-publisher-split-reimport.test.js +460 -0
  234. package/PCM/tests/e2e/digital-signage.test.js +145 -0
  235. package/PCM/tests/e2e/search-filter-pagination.test.js +399 -0
  236. package/PCM/tests/e2e-comprehensive.test.js +3446 -0
  237. package/PCM/tests/edge-cases.test.js +340 -0
  238. package/PCM/tests/line-item-status.test.js +340 -0
  239. package/PCM/tests/seller-account-external-ids.test.js +877 -0
  240. package/PCM/tests/source-validation.test.js +324 -0
  241. package/PRD.md +3373 -0
  242. package/README.md +186 -0
  243. package/client/index.html +35 -0
  244. package/client/public/DEMO_STATUS.md +579 -0
  245. package/client/public/img/MW-logo-trans_1754045676555.png +0 -0
  246. package/client/public/locales/ar/approval.json +144 -0
  247. package/client/public/locales/ar/buyer.json +61 -0
  248. package/client/public/locales/ar/campaigns.json +1 -0
  249. package/client/public/locales/ar/common.json +218 -0
  250. package/client/public/locales/ar/contentHub.json +266 -0
  251. package/client/public/locales/ar/creatives.json +79 -0
  252. package/client/public/locales/ar/dashboard.json +57 -0
  253. package/client/public/locales/ar/deals.json +886 -0
  254. package/client/public/locales/ar/dsp.json +131 -0
  255. package/client/public/locales/ar/inventory.json +201 -0
  256. package/client/public/locales/ar/lineItems.json +553 -0
  257. package/client/public/locales/ar/navigation.json +48 -0
  258. package/client/public/locales/ar/wizard.json +1 -0
  259. package/client/public/locales/en/approval.json +144 -0
  260. package/client/public/locales/en/buyer.json +65 -0
  261. package/client/public/locales/en/campaigns.json +1 -0
  262. package/client/public/locales/en/common.json +218 -0
  263. package/client/public/locales/en/contentHub.json +266 -0
  264. package/client/public/locales/en/creatives.json +79 -0
  265. package/client/public/locales/en/dashboard.json +57 -0
  266. package/client/public/locales/en/deals.json +886 -0
  267. package/client/public/locales/en/dsp.json +131 -0
  268. package/client/public/locales/en/inventory.json +201 -0
  269. package/client/public/locales/en/lineItems.json +659 -0
  270. package/client/public/locales/en/navigation.json +48 -0
  271. package/client/public/locales/en/wizard.json +1 -0
  272. package/client/public/locales/ja/approval.json +144 -0
  273. package/client/public/locales/ja/buyer.json +61 -0
  274. package/client/public/locales/ja/campaigns.json +1 -0
  275. package/client/public/locales/ja/common.json +218 -0
  276. package/client/public/locales/ja/contentHub.json +266 -0
  277. package/client/public/locales/ja/creatives.json +79 -0
  278. package/client/public/locales/ja/dashboard.json +57 -0
  279. package/client/public/locales/ja/deals.json +886 -0
  280. package/client/public/locales/ja/dsp.json +131 -0
  281. package/client/public/locales/ja/inventory.json +201 -0
  282. package/client/public/locales/ja/lineItems.json +553 -0
  283. package/client/public/locales/ja/navigation.json +48 -0
  284. package/client/public/locales/ja/wizard.json +1 -0
  285. package/client/public/locales/zh/approval.json +144 -0
  286. package/client/public/locales/zh/buyer.json +61 -0
  287. package/client/public/locales/zh/campaigns.json +1 -0
  288. package/client/public/locales/zh/common.json +218 -0
  289. package/client/public/locales/zh/contentHub.json +266 -0
  290. package/client/public/locales/zh/creatives.json +79 -0
  291. package/client/public/locales/zh/dashboard.json +57 -0
  292. package/client/public/locales/zh/deals.json +886 -0
  293. package/client/public/locales/zh/dsp.json +131 -0
  294. package/client/public/locales/zh/inventory.json +201 -0
  295. package/client/public/locales/zh/lineItems.json +553 -0
  296. package/client/public/locales/zh/navigation.json +48 -0
  297. package/client/public/locales/zh/wizard.json +1 -0
  298. package/client/public/manifest.json +36 -0
  299. package/client/src/App.tsx +464 -0
  300. package/client/src/components/app-sidebar.tsx +312 -0
  301. package/client/src/components/approval/approval-decision-form.test.tsx +294 -0
  302. package/client/src/components/approval/approval-decision-form.tsx +326 -0
  303. package/client/src/components/approval/approval-sheet.tsx +631 -0
  304. package/client/src/components/approval/line-item-details-sheet.tsx +371 -0
  305. package/client/src/components/approval/otp-verification.test.tsx +337 -0
  306. package/client/src/components/approval/otp-verification.tsx +180 -0
  307. package/client/src/components/content-hub/bulk-transcode-dialog.tsx +379 -0
  308. package/client/src/components/content-hub/content-hub-manager-v2.tsx +574 -0
  309. package/client/src/components/content-hub/content-hub-manager.tsx +330 -0
  310. package/client/src/components/content-hub/creative-card.tsx +456 -0
  311. package/client/src/components/content-hub/creative-detail-sheet.tsx +685 -0
  312. package/client/src/components/content-hub/creative-filters.tsx +457 -0
  313. package/client/src/components/content-hub/creative-grid.tsx +329 -0
  314. package/client/src/components/content-hub/creative-selector.tsx +415 -0
  315. package/client/src/components/content-hub/creative-upload.tsx +547 -0
  316. package/client/src/components/content-hub/folder-dialogs.tsx +445 -0
  317. package/client/src/components/content-hub/folder-list.tsx +280 -0
  318. package/client/src/components/content-hub/review-dialogs.tsx +268 -0
  319. package/client/src/components/content-hub/transcode-dialog.tsx +226 -0
  320. package/client/src/components/creative-library/creative-details-view.tsx +446 -0
  321. package/client/src/components/creative-library/creative-filters-panel.tsx +203 -0
  322. package/client/src/components/creative-library/creative-list.tsx +360 -0
  323. package/client/src/components/creative-library/creative-status-badge.tsx +71 -0
  324. package/client/src/components/creative-library/folder-card.tsx +78 -0
  325. package/client/src/components/creative-library/index.ts +27 -0
  326. package/client/src/components/creative-library/new-creative-card.tsx +211 -0
  327. package/client/src/components/creative-library/upload-creative-dialog.tsx +261 -0
  328. package/client/src/components/dashboard-overview.tsx +109 -0
  329. package/client/src/components/deals/approval-history-panel.test.tsx +240 -0
  330. package/client/src/components/deals/approval-history-panel.tsx +156 -0
  331. package/client/src/components/deals/deal-status-badge.tsx +92 -0
  332. package/client/src/components/deals/import-from-planner-dialog.tsx +399 -0
  333. package/client/src/components/deals/market-insights-panel.tsx +237 -0
  334. package/client/src/components/deals/reopen-deal-sheet.tsx +191 -0
  335. package/client/src/components/deals/request-approval-sheet.test.tsx +323 -0
  336. package/client/src/components/deals/request-approval-sheet.tsx +136 -0
  337. package/client/src/components/deals/resend-approval-sheet.tsx +201 -0
  338. package/client/src/components/direct-campaigns/campaign-card.tsx +283 -0
  339. package/client/src/components/direct-campaigns/deal-filter-panel.tsx +325 -0
  340. package/client/src/components/inventory/advanced-filters-panel.tsx +273 -0
  341. package/client/src/components/inventory/csv-upload-modal.tsx +639 -0
  342. package/client/src/components/inventory/inventory-availability-view.tsx +486 -0
  343. package/client/src/components/inventory/inventory-details-sheet.tsx +376 -0
  344. package/client/src/components/inventory/inventory-map-view.tsx +596 -0
  345. package/client/src/components/inventory/inventory-settings-menu.tsx +52 -0
  346. package/client/src/components/language-switcher.tsx +53 -0
  347. package/client/src/components/line-items/campaign-forecast-panel.tsx +138 -0
  348. package/client/src/components/line-items/form-insights.tsx +89 -0
  349. package/client/src/components/line-items/geofencing/LocationCsvUploadDrawer.tsx +100 -0
  350. package/client/src/components/line-items/geofencing/POIDropdown.tsx +379 -0
  351. package/client/src/components/line-items/geofencing/SelectedLocationsSidebar.tsx +436 -0
  352. package/client/src/components/line-items/geofencing/ViewFileLocationDrawer.tsx +199 -0
  353. package/client/src/components/line-items/geofencing/components/ExistingFilesTab.tsx +268 -0
  354. package/client/src/components/line-items/geofencing/components/TemplateDownloadSection.tsx +59 -0
  355. package/client/src/components/line-items/geofencing/components/UploadTab.tsx +215 -0
  356. package/client/src/components/line-items/geofencing-map.tsx +1270 -0
  357. package/client/src/components/line-items/inventory-availability-section.tsx +178 -0
  358. package/client/src/components/line-items/line-item-schedule-manager.tsx +313 -0
  359. package/client/src/components/line-items/manual-inventory-drawer.tsx +346 -0
  360. package/client/src/components/line-items/planner-inventory-card.tsx +495 -0
  361. package/client/src/components/line-items/planner-schedule-grid.tsx +495 -0
  362. package/client/src/components/line-items/schedule-rule-editor.tsx +649 -0
  363. package/client/src/components/line-items/schedule-rule-types.ts +122 -0
  364. package/client/src/components/line-items/steps/creatives-step.tsx +681 -0
  365. package/client/src/components/line-items/steps/inventory-schedule-step.tsx +1596 -0
  366. package/client/src/components/line-items/steps/inventory-step.tsx +1533 -0
  367. package/client/src/components/line-items/steps/line-item-details-step.tsx +916 -0
  368. package/client/src/components/line-items/steps/schedule-step.tsx +273 -0
  369. package/client/src/components/line-items/steps/summary-step.tsx +680 -0
  370. package/client/src/components/line-items/steps/targeting-step.tsx +1708 -0
  371. package/client/src/components/product-switcher.tsx +105 -0
  372. package/client/src/components/protected-route.tsx +49 -0
  373. package/client/src/components/skip-link.tsx +22 -0
  374. package/client/src/components/stat-card.tsx +53 -0
  375. package/client/src/components/status-badge.tsx +96 -0
  376. package/client/src/components/ui/hierarchical-venue-selector.tsx +389 -0
  377. package/client/src/components/ui/toaster.tsx +111 -0
  378. package/client/src/contexts/auth-context.tsx +181 -0
  379. package/client/src/contexts/sidebar-state.tsx +50 -0
  380. package/client/src/contexts/theme-context.tsx +66 -0
  381. package/client/src/data/campaign-data.json +107 -0
  382. package/client/src/data/countries.json +22 -0
  383. package/client/src/hooks/use-approval.ts +366 -0
  384. package/client/src/hooks/use-keyboard-shortcuts.ts +74 -0
  385. package/client/src/hooks/use-media-query.ts +46 -0
  386. package/client/src/hooks/use-mobile.tsx +19 -0
  387. package/client/src/hooks/use-page-title.ts +21 -0
  388. package/client/src/hooks/use-toast.ts +195 -0
  389. package/client/src/index.css +694 -0
  390. package/client/src/lib/__tests__/accessibility.test.ts +104 -0
  391. package/client/src/lib/__tests__/date-utils.test.ts +199 -0
  392. package/client/src/lib/__tests__/dsp-buyer-api.test.ts +127 -0
  393. package/client/src/lib/__tests__/dsp-buyer-integration.test.ts +247 -0
  394. package/client/src/lib/__tests__/storage-utils.test.ts +167 -0
  395. package/client/src/lib/__tests__/utils.test.ts +57 -0
  396. package/client/src/lib/accessibility.ts +141 -0
  397. package/client/src/lib/api-config.ts +9 -0
  398. package/client/src/lib/auth-service.ts +209 -0
  399. package/client/src/lib/campaign-creative-api.ts +82 -0
  400. package/client/src/lib/company-api.ts +61 -0
  401. package/client/src/lib/content-hub-api.ts +407 -0
  402. package/client/src/lib/creative-mapper.ts +61 -0
  403. package/client/src/lib/date-utils.ts +119 -0
  404. package/client/src/lib/deal-helpers.ts +220 -0
  405. package/client/src/lib/dsp-buyer-api.ts +196 -0
  406. package/client/src/lib/geo-import-api.ts +151 -0
  407. package/client/src/lib/google-poi-api.ts +305 -0
  408. package/client/src/lib/i18n/__tests__/formatting.test.ts +202 -0
  409. package/client/src/lib/i18n/formatting.ts +130 -0
  410. package/client/src/lib/i18n/index.ts +8 -0
  411. package/client/src/lib/i18n-compat.ts +76 -0
  412. package/client/src/lib/influence-deals-api.ts +896 -0
  413. package/client/src/lib/inventory-api.ts +399 -0
  414. package/client/src/lib/oauth-service.ts +678 -0
  415. package/client/src/lib/poi-types.ts +75 -0
  416. package/client/src/lib/queryClient.ts +144 -0
  417. package/client/src/lib/recommendation-api.ts +380 -0
  418. package/client/src/lib/storage-utils.ts +104 -0
  419. package/client/src/lib/tolgee.ts +85 -0
  420. package/client/src/lib/utils.ts +0 -0
  421. package/client/src/main.tsx +67 -0
  422. package/client/src/mapbox-draw-modes.d.ts +32 -0
  423. package/client/src/pages/all-folders.tsx +203 -0
  424. package/client/src/pages/auth-callback.tsx +115 -0
  425. package/client/src/pages/buyer-form.tsx +339 -0
  426. package/client/src/pages/buyer-list.tsx +622 -0
  427. package/client/src/pages/content-hub.tsx +1358 -0
  428. package/client/src/pages/create-deal.tsx +2093 -0
  429. package/client/src/pages/creative-assignment-page.tsx +548 -0
  430. package/client/src/pages/creatives.tsx +5 -0
  431. package/client/src/pages/custom-pois.tsx +425 -0
  432. package/client/src/pages/dashboard.tsx +615 -0
  433. package/client/src/pages/deal-history.tsx +434 -0
  434. package/client/src/pages/deal-line-items.tsx +1703 -0
  435. package/client/src/pages/demo-status.tsx +113 -0
  436. package/client/src/pages/direct-campaign-details.tsx +361 -0
  437. package/client/src/pages/direct-campaigns-new.tsx +824 -0
  438. package/client/src/pages/dsp-form.tsx +803 -0
  439. package/client/src/pages/dsp-list.tsx +239 -0
  440. package/client/src/pages/folder-content.tsx +336 -0
  441. package/client/src/pages/integrations.tsx +429 -0
  442. package/client/src/pages/line-item-creatives.tsx +789 -0
  443. package/client/src/pages/line-item-detail-page.tsx +684 -0
  444. package/client/src/pages/line-item-form-page.tsx +3261 -0
  445. package/client/src/pages/line-item-wizard.tsx +1207 -0
  446. package/client/src/pages/login.tsx +154 -0
  447. package/client/src/pages/not-found.tsx +23 -0
  448. package/client/src/pages/proof-of-play.tsx +397 -0
  449. package/client/src/pages/public-approval.tsx +551 -0
  450. package/client/src/pages/reports.tsx +231 -0
  451. package/client/src/pages/settings.tsx +760 -0
  452. package/client/src/pages/signals.tsx +389 -0
  453. package/client/src/pages/tags.tsx +318 -0
  454. package/client/src/pages/test-results.tsx +328 -0
  455. package/client/src/store/hooks.ts +5 -0
  456. package/client/src/store/index.ts +15 -0
  457. package/client/src/store/mapMarkerLocationsSlice.ts +241 -0
  458. package/client/src/styles/design-tokens.css +324 -0
  459. package/client/src/test/setup.ts +261 -0
  460. package/client/src/test/test-utils.tsx +40 -0
  461. package/client/src/types/approval.ts +221 -0
  462. package/client/src/types/content-hub.ts +209 -0
  463. package/client/src/types/geofencing.ts +67 -0
  464. package/client/src/types/transcoding.ts +140 -0
  465. package/client/src/vite-env.d.ts +18 -0
  466. package/components.json +20 -0
  467. package/creative-api.json +1 -0
  468. package/docs/AI_REFERENCE.md +459 -0
  469. package/docs/MWDesign-Prompt.md +132 -0
  470. package/docs/MWDesign-System.md +344 -0
  471. package/docs/test-plan.md +277 -0
  472. package/e2e/AUTONOMOUS-TESTING.md +406 -0
  473. package/e2e/README.md +219 -0
  474. package/e2e/autonomous-flow.spec.ts +308 -0
  475. package/e2e/debug-sso.spec.ts +163 -0
  476. package/e2e/direct-campaigns.spec.ts +219 -0
  477. package/e2e/explore-sso.spec.ts +149 -0
  478. package/e2e/fixtures/auth.ts +26 -0
  479. package/e2e/fixtures/enhanced-test.ts +331 -0
  480. package/e2e/pagination.spec.ts +280 -0
  481. package/e2e/view-toggle.spec.ts +312 -0
  482. package/generated-icon.png +0 -0
  483. package/i18next-scanner.config.cjs +46 -0
  484. package/package.json +141 -0
  485. package/playwright.config.ts +93 -0
  486. package/postcss.config.js +6 -0
  487. package/replit.md +196 -0
  488. package/screenshot-after-login.png +0 -0
  489. package/screenshot-contenthub-grid.png +0 -0
  490. package/screenshot-contenthub-list-fixed.png +0 -0
  491. package/screenshot-contenthub-list.png +0 -0
  492. package/screenshot-create-deal.png +0 -0
  493. package/screenshot-dashboard.png +0 -0
  494. package/screenshot-deals.png +0 -0
  495. package/screenshot-login-filled.png +0 -0
  496. package/screenshot-login.png +0 -0
  497. package/screenshot.mjs +24 -0
  498. package/scripts/deploy-stg.sh +185 -0
  499. package/shared/direct-io-schema.ts +383 -0
  500. package/shared/schema.ts +439 -0
  501. package/shared/screen-types.ts +149 -0
  502. package/springdocDefault.json +1 -0
  503. package/swagger-ui-bundle.js +2 -0
  504. package/swagger-ui-init.js +10316 -0
  505. package/tailwind.config.ts +282 -0
  506. package/terraform/README.md +306 -0
  507. package/terraform/cloudfront.tf +289 -0
  508. package/terraform/ecs.tf +727 -0
  509. package/terraform/environments/dev.tfvars +59 -0
  510. package/terraform/environments/production.tfvars +60 -0
  511. package/terraform/main.tf +47 -0
  512. package/terraform/outputs.tf +145 -0
  513. package/terraform/s3.tf +192 -0
  514. package/terraform/variables.tf +226 -0
  515. package/terraform/waf.tf +165 -0
  516. package/terraform-frontend/.terraform.lock.hcl +25 -0
  517. package/terraform-frontend/README.md +85 -0
  518. package/terraform-frontend/cloudfront.tf +125 -0
  519. package/terraform-frontend/main.tf +31 -0
  520. package/terraform-frontend/outputs.tf +24 -0
  521. package/terraform-frontend/terraform.tfvars +12 -0
  522. package/terraform-frontend/variables.tf +53 -0
  523. package/tsconfig.json +23 -0
  524. package/vite.config.ts +226 -0
  525. package/vitest.config.ts +56 -0
@@ -0,0 +1,3132 @@
1
+ import { useEffect, useState, useMemo, useRef, useCallback } from "react";
2
+ import { useQuery, useMutation } from "@tanstack/react-query";
3
+ import { useRoute, useLocation } from "wouter";
4
+ import { useForm, useFieldArray, useWatch } from "react-hook-form";
5
+ import { zodResolver } from "@hookform/resolvers/zod";
6
+ import { z } from "zod";
7
+ import mapboxgl from "mapbox-gl";
8
+ import { ArrowLeft, Loader2, ChevronDown, ChevronUp, Plus, Trash2, TrendingUp, CalendarIcon, Clock, Lock, Monitor, MapPin, RefreshCw, Edit2, Globe, Check, FileText, BarChart3, AlertCircle, DollarSign, Users, Target, Zap, Layers, Copy, Receipt, Server, Building, Layout, CircleDollarSign, CalendarRange, Gauge, Settings } from "lucide-react";
9
+ import { format, parse } from "date-fns";
10
+ import { Calendar } from "@/components/ui/calendar";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Input } from "@/components/ui/input";
13
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
14
+ import { Checkbox } from "@/components/ui/checkbox";
15
+ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
16
+ import {
17
+ Collapsible,
18
+ CollapsibleContent,
19
+ CollapsibleTrigger,
20
+ } from "@/components/ui/collapsible";
21
+ import {
22
+ Sheet,
23
+ SheetContent,
24
+ SheetDescription,
25
+ SheetHeader,
26
+ SheetTitle,
27
+ } from "@/components/ui/sheet";
28
+ import { ScrollArea } from "@/components/ui/scroll-area";
29
+ import "mapbox-gl/dist/mapbox-gl.css";
30
+
31
+ const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_PUBLIC_KEY || "";
32
+ import {
33
+ Popover,
34
+ PopoverContent,
35
+ PopoverTrigger,
36
+ } from "@/components/ui/popover";
37
+ import {
38
+ Form,
39
+ FormControl,
40
+ FormField,
41
+ FormItem,
42
+ FormLabel,
43
+ FormMessage,
44
+ FormDescription,
45
+ } from "@/components/ui/form";
46
+ import {
47
+ Select,
48
+ SelectContent,
49
+ SelectItem,
50
+ SelectTrigger,
51
+ SelectValue,
52
+ } from "@/components/ui/select";
53
+ import { Slider } from "@/components/ui/slider";
54
+ import { PageHeader } from "@/components/page-header";
55
+ import { SearchableCombobox, ComboboxOption } from "@/components/searchable-combobox";
56
+ import { TrafficSlider } from "@/components/traffic-slider";
57
+ import { Skeleton } from "@/components/ui/skeleton";
58
+ import { Badge } from "@/components/ui/badge";
59
+ import { useToast } from "@/hooks/use-toast";
60
+ import { apiRequest, queryClient } from "@/lib/queryClient";
61
+ import { FormInsightsPanel } from "@/components/form-insights-panel";
62
+ import type { CampaignGoalType } from "@shared/schema";
63
+ import { VenueTypeTrigger } from "@/components/venue-type-selector";
64
+ import { InventorySelector } from "@/components/inventory-selector";
65
+ import { MediaOwnerDrawer } from "@/components/media-owner-drawer";
66
+ import { InventoryFormatDrawer } from "@/components/inventory-format-drawer";
67
+ import { POITargetingDrawer } from "@/components/poi-targeting-drawer";
68
+ import { AdvancedMapDrawer } from "@/components/advanced-map-drawer";
69
+ import { VenueTypeDrawer } from "@/components/venue-type-drawer";
70
+ import { InventoryAvailabilitySection } from "@/components/inventory-availability-section";
71
+ import { AvailabilityDrawer } from "@/components/availability-drawer";
72
+ import { AIRecommendationPanel } from "@/components/ai-recommendation-panel";
73
+ import { ManualInventoryDrawer } from "@/components/manual-inventory-drawer";
74
+ import type { LineItem, Deal, Screen, DspPartner, Signal } from "@shared/schema";
75
+ import { Switch } from "@/components/ui/switch";
76
+ import {
77
+ CREATIVE_TYPES,
78
+ PACING_OPTIONS,
79
+ INCOME_BRACKETS,
80
+ AGE_GROUPS,
81
+ GENDERS,
82
+ BEHAVIORS,
83
+ INTERESTS,
84
+ INVENTORY_TYPES,
85
+ INVENTORY_CLASSIFICATIONS,
86
+ INVENTORY_FORMATS,
87
+ INVENTORY_FORMATS_BY_TYPE,
88
+ SCREEN_RESOLUTIONS,
89
+ COUNTRIES,
90
+ } from "@shared/schema";
91
+
92
+ // Mock inventory rates - In production, these come from Inventory Management API
93
+ const INVENTORY_RATES: Record<string, { cpm: number; cps: number }> = {
94
+ // By classification
95
+ "Digital": { cpm: 15.00, cps: 1.50 },
96
+ // By type
97
+ "OOH": { cpm: 12.00, cps: 1.20 },
98
+ "Transit": { cpm: 10.00, cps: 1.00 },
99
+ "Retail": { cpm: 14.00, cps: 1.40 },
100
+ // By format
101
+ "Static Billboards": { cpm: 6.00, cps: 0.60 },
102
+ "Digital LED Billboards": { cpm: 18.00, cps: 1.80 },
103
+ "Street Furniture / Transit Shelters": { cpm: 9.00, cps: 0.90 },
104
+ "Mall / Retail Screens": { cpm: 16.00, cps: 1.60 },
105
+ "Cinema Pre-Show / Lobby": { cpm: 22.00, cps: 2.20 },
106
+ "Airport Terminal Screens": { cpm: 25.00, cps: 2.50 },
107
+ "Elevator / Lobby Screens": { cpm: 11.00, cps: 1.10 },
108
+ "Digital Video Walls": { cpm: 20.00, cps: 2.00 },
109
+ "Digital Kiosk": { cpm: 13.00, cps: 1.30 },
110
+ "Programmatic DOOH": { cpm: 17.00, cps: 1.70 },
111
+ // By size category
112
+ "XS": { cpm: 5.00, cps: 0.50 },
113
+ "S": { cpm: 8.00, cps: 0.80 },
114
+ "M": { cpm: 12.00, cps: 1.20 },
115
+ "L": { cpm: 18.00, cps: 1.80 },
116
+ "XL": { cpm: 25.00, cps: 2.50 },
117
+ };
118
+
119
+ const LINE_ITEM_INSIGHTS = [
120
+ { text: "Targeting determines audience reach - broader targeting increases potential impressions but may reduce relevance" },
121
+ { text: "Inventory selection affects available impressions - more formats and classifications expand your reach" },
122
+ { text: "Max bid impacts win rate in programmatic - higher bids increase win rate but may affect budget efficiency" },
123
+ ];
124
+
125
+ const LINE_ITEM_TIPS = [
126
+ { text: "Start with broader targeting and narrow based on performance data" },
127
+ { text: "Use suggested max bid based on inventory selection for optimal results" },
128
+ { text: "Monitor pacing to optimize delivery and prevent early budget exhaustion" },
129
+ ];
130
+
131
+ const SECTION_INSIGHTS = [
132
+ {
133
+ section: "Basic Details",
134
+ insights: [
135
+ { text: "The line item name helps identify this campaign in reports and dashboards" },
136
+ { text: "Creative type determines which creative formats can be assigned" },
137
+ ],
138
+ tips: [
139
+ { text: "Use descriptive names that include campaign objective and date range" },
140
+ { text: "Copy settings from existing line items to save time on similar campaigns" },
141
+ ],
142
+ },
143
+ {
144
+ section: "Targeting",
145
+ insights: [
146
+ { text: "Demographic targeting helps reach specific audience segments" },
147
+ { text: "POI targeting places ads near relevant points of interest" },
148
+ ],
149
+ tips: [
150
+ { text: "Combine age and gender targeting for more precise audience reach" },
151
+ { text: "Use POI categories for broader reach or specific POIs for precision" },
152
+ ],
153
+ },
154
+ {
155
+ section: "Inventory",
156
+ insights: [
157
+ { text: "Media owner selection affects available screen inventory" },
158
+ { text: "Format selection determines compatible creative specifications" },
159
+ ],
160
+ tips: [
161
+ { text: "Use AI recommendations to find optimal inventory for your goals" },
162
+ { text: "Balance format variety with creative production capabilities" },
163
+ ],
164
+ },
165
+ {
166
+ section: "Schedule",
167
+ insights: [
168
+ { text: "Flight dates determine the campaign delivery window" },
169
+ { text: "Day-parting optimizes delivery during peak audience times" },
170
+ ],
171
+ tips: [
172
+ { text: "Schedule ads during peak traffic hours for maximum visibility" },
173
+ { text: "Use dayparting to avoid off-hours when audience is minimal" },
174
+ ],
175
+ },
176
+ {
177
+ section: "Budget",
178
+ insights: [
179
+ { text: "Budget pacing controls how quickly your budget is spent" },
180
+ { text: "Traffic allocation distributes impressions across line items" },
181
+ ],
182
+ tips: [
183
+ { text: "Even pacing ensures consistent delivery throughout the campaign" },
184
+ { text: "Front-loaded pacing is useful for time-sensitive promotions" },
185
+ ],
186
+ },
187
+ ];
188
+
189
+ const formatNumber = (num: number): string => {
190
+ return new Intl.NumberFormat('en-US').format(num);
191
+ };
192
+
193
+ const formatCurrency = (num: number): string => {
194
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
195
+ };
196
+
197
+ const customFeeSchema = z.object({
198
+ name: z.string().min(1, "Fee name is required"),
199
+ amount: z.number().min(0, "Amount must be positive"),
200
+ type: z.enum(["fixed", "percentage"]),
201
+ hidden: z.boolean().default(false),
202
+ });
203
+
204
+ const lineItemFormSchema = z.object({
205
+ name: z.string().min(1, "Line item name is required"),
206
+ dealId: z.string().min(1, "Please select a deal"),
207
+ status: z.enum(["active", "paused", "draft"]).default("draft"),
208
+ copiedFromId: z.string().optional(),
209
+ copyFromLineItemId: z.string().optional(),
210
+ creativeType: z.enum(["display", "video", "audio"]).default("display"),
211
+ priority: z.number().min(1).max(10).default(5),
212
+ demographics: z.array(z.string()).default([]),
213
+ geography: z.array(z.string()).default([]),
214
+ selectedPOIs: z.array(z.string()).default([]),
215
+ incomeBrackets: z.array(z.string()).default([]),
216
+ behaviors: z.array(z.string()).default([]),
217
+ interests: z.array(z.string()).default([]),
218
+ inventoryClassification: z.array(z.string()).default([]),
219
+ inventoryType: z.array(z.string()).default([]),
220
+ inventoryFormat: z.array(z.string()).default([]),
221
+ selectedScreens: z.array(z.string()).default([]),
222
+ startDate: z.string().optional(),
223
+ endDate: z.string().optional(),
224
+ schedules: z.array(z.object({
225
+ days: z.array(z.number()),
226
+ startHour: z.number().min(0).max(23),
227
+ endHour: z.number().min(0).max(23),
228
+ })).default([]),
229
+ budget: z.string().optional(),
230
+ pacing: z.enum(["asap", "even", "front_loaded"]).default("even"),
231
+ trafficAllocation: z.number().min(0).max(100).default(100),
232
+ customFees: z.array(customFeeSchema).default([]),
233
+ frequencyCapImpressions: z.string().optional(),
234
+ frequencyCapPeriod: z.enum(["hour", "day", "week", "month", "lifetime"]).optional(),
235
+ dspId: z.string().optional(),
236
+ dspSeatId: z.string().optional(),
237
+ pushToDsp: z.boolean().default(false),
238
+ creativeDuration: z.number().min(1).default(10),
239
+ triggerEnabled: z.boolean().default(false),
240
+ triggerId: z.string().optional(),
241
+ resolution: z.string().optional().default(""),
242
+ venueTypes: z.array(z.string()).default([]),
243
+ adResolutions: z.array(z.string()).default([]),
244
+ adDurations: z.array(z.number()).default([]),
245
+ billable: z.boolean().default(true),
246
+ unlimitedBudget: z.boolean().default(false),
247
+ budgetConsumption: z.enum(["daily", "weekly", "monthly", "lifetime"]).default("daily"),
248
+ dailyBudget: z.string().optional(),
249
+ currency: z.string().default("USD"),
250
+ automatedBidding: z.boolean().default(false),
251
+ maxBid: z.string().optional(),
252
+ bidType: z.enum(["cpm", "cps"]).default("cpm"),
253
+ });
254
+
255
+ const DURATION_OPTIONS = [5, 10, 15, 20, 30];
256
+
257
+ const COUNTRY_CENTERS: Record<string, { lng: number; lat: number; zoom: number }> = {
258
+ Malaysia: { lng: 101.9758, lat: 4.2105, zoom: 5 },
259
+ Singapore: { lng: 103.8198, lat: 1.3521, zoom: 10 },
260
+ Thailand: { lng: 100.5018, lat: 13.7563, zoom: 5 },
261
+ Indonesia: { lng: 106.8456, lat: -6.2088, zoom: 4 },
262
+ Philippines: { lng: 121.774, lat: 12.8797, zoom: 5 },
263
+ "United States": { lng: -98.5795, lat: 39.8283, zoom: 3 },
264
+ };
265
+
266
+ type LineItemFormData = z.infer<typeof lineItemFormSchema>;
267
+
268
+ interface MultiSelectFieldProps {
269
+ label: string;
270
+ options: string[];
271
+ value: string[];
272
+ onChange: (value: string[]) => void;
273
+ testIdPrefix: string;
274
+ }
275
+
276
+ function MultiSelectField({ label, options, value, onChange, testIdPrefix }: MultiSelectFieldProps) {
277
+ const [isOpen, setIsOpen] = useState(false);
278
+
279
+ const handleToggle = (option: string, checked: boolean) => {
280
+ if (checked) {
281
+ onChange([...value, option]);
282
+ } else {
283
+ onChange(value.filter((v) => v !== option));
284
+ }
285
+ };
286
+
287
+ return (
288
+ <FormItem>
289
+ <FormLabel>{label}</FormLabel>
290
+ <Popover open={isOpen} onOpenChange={setIsOpen}>
291
+ <PopoverTrigger asChild>
292
+ <Button
293
+ type="button"
294
+ variant="outline"
295
+ className="w-full justify-between font-normal"
296
+ data-testid={`${testIdPrefix}-trigger`}
297
+ >
298
+ <span className="text-muted-foreground">
299
+ {value.length > 0 ? `${value.length} selected` : `Select ${label.toLowerCase()}...`}
300
+ </span>
301
+ {isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
302
+ </Button>
303
+ </PopoverTrigger>
304
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
305
+ <div className="p-3 max-h-48 overflow-y-auto space-y-2">
306
+ {options.map((option) => (
307
+ <label
308
+ key={option}
309
+ className="flex items-center gap-2 cursor-pointer hover-elevate p-1 rounded"
310
+ >
311
+ <Checkbox
312
+ checked={value.includes(option)}
313
+ onCheckedChange={(checked) => handleToggle(option, checked as boolean)}
314
+ data-testid={`${testIdPrefix}-${option.toLowerCase().replace(/\s+/g, "-")}`}
315
+ />
316
+ <span className="text-sm">{option}</span>
317
+ </label>
318
+ ))}
319
+ </div>
320
+ </PopoverContent>
321
+ </Popover>
322
+ {value.length > 0 && (
323
+ <div className="flex flex-wrap gap-1 mt-2">
324
+ {value.map((v) => (
325
+ <Badge key={v} variant="secondary" className="text-xs">
326
+ {v}
327
+ </Badge>
328
+ ))}
329
+ </div>
330
+ )}
331
+ </FormItem>
332
+ );
333
+ }
334
+
335
+ const DAYS_OF_WEEK = [
336
+ { value: 0, label: "Sun" },
337
+ { value: 1, label: "Mon" },
338
+ { value: 2, label: "Tue" },
339
+ { value: 3, label: "Wed" },
340
+ { value: 4, label: "Thu" },
341
+ { value: 5, label: "Fri" },
342
+ { value: 6, label: "Sat" },
343
+ ];
344
+
345
+ const HOURS = Array.from({ length: 24 }, (_, i) => ({
346
+ value: i,
347
+ label: i.toString().padStart(2, "0") + ":00",
348
+ }));
349
+
350
+ interface Schedule {
351
+ days: number[];
352
+ startHour: number;
353
+ endHour: number;
354
+ }
355
+
356
+ interface ScheduleEditorProps {
357
+ value: Schedule[];
358
+ onChange: (value: Schedule[]) => void;
359
+ }
360
+
361
+ function ScheduleEditor({ value, onChange }: ScheduleEditorProps) {
362
+ const [isOpen, setIsOpen] = useState(false);
363
+
364
+ const addSchedule = () => {
365
+ onChange([...value, { days: [1, 2, 3, 4, 5], startHour: 9, endHour: 17 }]);
366
+ };
367
+
368
+ const removeSchedule = (index: number) => {
369
+ onChange(value.filter((_, i) => i !== index));
370
+ };
371
+
372
+ const updateSchedule = (index: number, updates: Partial<Schedule>) => {
373
+ const newSchedules = [...value];
374
+ newSchedules[index] = { ...newSchedules[index], ...updates };
375
+ onChange(newSchedules);
376
+ };
377
+
378
+ const toggleDay = (scheduleIndex: number, day: number) => {
379
+ const schedule = value[scheduleIndex];
380
+ const newDays = schedule.days.includes(day)
381
+ ? schedule.days.filter(d => d !== day)
382
+ : [...schedule.days, day].sort((a, b) => a - b);
383
+ updateSchedule(scheduleIndex, { days: newDays });
384
+ };
385
+
386
+ const formatScheduleSummary = (schedule: Schedule) => {
387
+ const dayNames = schedule.days.map(d => DAYS_OF_WEEK.find(dw => dw.value === d)?.label).join(", ");
388
+ return `${dayNames} ${schedule.startHour.toString().padStart(2, "0")}:00-${schedule.endHour.toString().padStart(2, "0")}:00`;
389
+ };
390
+
391
+ return (
392
+ <FormItem>
393
+ <FormLabel>Schedules</FormLabel>
394
+ <Collapsible open={isOpen} onOpenChange={setIsOpen}>
395
+ <CollapsibleTrigger asChild>
396
+ <Button
397
+ type="button"
398
+ variant="outline"
399
+ className="w-full justify-between font-normal"
400
+ data-testid="schedules-trigger"
401
+ >
402
+ <span className="text-muted-foreground flex items-center gap-2">
403
+ <Clock className="h-4 w-4" />
404
+ {value.length > 0 ? `${value.length} schedule${value.length !== 1 ? "s" : ""} configured` : "No schedules (runs all day)"}
405
+ </span>
406
+ {isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
407
+ </Button>
408
+ </CollapsibleTrigger>
409
+ <CollapsibleContent className="mt-2 space-y-3">
410
+ {value.map((schedule, index) => (
411
+ <Card key={index} className="p-3">
412
+ <div className="space-y-3">
413
+ <div className="flex items-center justify-between">
414
+ <span className="text-sm font-medium">Schedule {index + 1}</span>
415
+ <Button
416
+ type="button"
417
+ variant="ghost"
418
+ size="sm"
419
+ onClick={() => removeSchedule(index)}
420
+ className="text-destructive hover:text-destructive"
421
+ data-testid={`button-remove-schedule-${index}`}
422
+ >
423
+ <Trash2 className="h-4 w-4" />
424
+ </Button>
425
+ </div>
426
+
427
+ <div>
428
+ <label className="text-xs text-muted-foreground mb-1 block">Days</label>
429
+ <div className="flex gap-1">
430
+ {DAYS_OF_WEEK.map(day => (
431
+ <Button
432
+ key={day.value}
433
+ type="button"
434
+ variant={schedule.days.includes(day.value) ? "default" : "outline"}
435
+ size="sm"
436
+ className="px-2 py-1 h-8 text-xs"
437
+ onClick={() => toggleDay(index, day.value)}
438
+ data-testid={`button-day-${index}-${day.value}`}
439
+ >
440
+ {day.label}
441
+ </Button>
442
+ ))}
443
+ </div>
444
+ </div>
445
+
446
+ <div className="grid grid-cols-2 gap-3">
447
+ <div>
448
+ <label className="text-xs text-muted-foreground mb-1 block">Start Hour</label>
449
+ <Select
450
+ value={schedule.startHour.toString()}
451
+ onValueChange={(v) => updateSchedule(index, { startHour: parseInt(v) })}
452
+ >
453
+ <SelectTrigger data-testid={`select-start-hour-${index}`}>
454
+ <SelectValue />
455
+ </SelectTrigger>
456
+ <SelectContent>
457
+ {HOURS.map(h => (
458
+ <SelectItem key={h.value} value={h.value.toString()}>
459
+ {h.label}
460
+ </SelectItem>
461
+ ))}
462
+ </SelectContent>
463
+ </Select>
464
+ </div>
465
+ <div>
466
+ <label className="text-xs text-muted-foreground mb-1 block">End Hour</label>
467
+ <Select
468
+ value={schedule.endHour.toString()}
469
+ onValueChange={(v) => updateSchedule(index, { endHour: parseInt(v) })}
470
+ >
471
+ <SelectTrigger data-testid={`select-end-hour-${index}`}>
472
+ <SelectValue />
473
+ </SelectTrigger>
474
+ <SelectContent>
475
+ {HOURS.map(h => (
476
+ <SelectItem key={h.value} value={h.value.toString()}>
477
+ {h.label}
478
+ </SelectItem>
479
+ ))}
480
+ </SelectContent>
481
+ </Select>
482
+ </div>
483
+ </div>
484
+ </div>
485
+ </Card>
486
+ ))}
487
+
488
+ <Button
489
+ type="button"
490
+ variant="outline"
491
+ size="sm"
492
+ onClick={addSchedule}
493
+ className="w-full"
494
+ data-testid="button-add-schedule"
495
+ >
496
+ <Plus className="h-4 w-4 mr-2" />
497
+ Add Schedule
498
+ </Button>
499
+ </CollapsibleContent>
500
+ </Collapsible>
501
+
502
+ {value.length > 0 && !isOpen && (
503
+ <div className="flex flex-wrap gap-1 mt-2">
504
+ {value.map((schedule, i) => (
505
+ <Badge key={i} variant="secondary" className="text-xs">
506
+ {formatScheduleSummary(schedule)}
507
+ </Badge>
508
+ ))}
509
+ </div>
510
+ )}
511
+ </FormItem>
512
+ );
513
+ }
514
+
515
+ interface DatePickerFieldProps {
516
+ value: string | undefined;
517
+ onChange: (value: string) => void;
518
+ label: string;
519
+ testId: string;
520
+ required?: boolean;
521
+ }
522
+
523
+ function DatePickerField({ value, onChange, label, testId, required }: DatePickerFieldProps) {
524
+ const [open, setOpen] = useState(false);
525
+
526
+ const selectedDate = value ? parse(value, "yyyy-MM-dd", new Date()) : undefined;
527
+
528
+ const handleSelect = (date: Date | undefined) => {
529
+ if (date) {
530
+ onChange(format(date, "yyyy-MM-dd"));
531
+ setOpen(false);
532
+ }
533
+ };
534
+
535
+ return (
536
+ <FormItem>
537
+ <FormLabel>{label} {required && <span className="text-destructive">*</span>}</FormLabel>
538
+ <Popover open={open} onOpenChange={setOpen}>
539
+ <PopoverTrigger asChild>
540
+ <Button
541
+ type="button"
542
+ variant="outline"
543
+ className="w-full justify-start text-left font-normal"
544
+ data-testid={testId}
545
+ >
546
+ <CalendarIcon className="mr-2 h-4 w-4" />
547
+ {selectedDate ? format(selectedDate, "MMM dd, yyyy") : <span className="text-muted-foreground">Select date...</span>}
548
+ </Button>
549
+ </PopoverTrigger>
550
+ <PopoverContent className="w-auto p-0" align="start">
551
+ <Calendar
552
+ mode="single"
553
+ selected={selectedDate}
554
+ onSelect={handleSelect}
555
+ initialFocus
556
+ />
557
+ </PopoverContent>
558
+ </Popover>
559
+ </FormItem>
560
+ );
561
+ }
562
+
563
+ function InventoryMapComponent({
564
+ center,
565
+ zoom,
566
+ screens,
567
+ selectedIds,
568
+ onScreenClick,
569
+ }: {
570
+ center: { lng: number; lat: number };
571
+ zoom: number;
572
+ screens: Screen[];
573
+ selectedIds: Set<string>;
574
+ onScreenClick: (id: string) => void;
575
+ }) {
576
+ const mapContainer = useRef<HTMLDivElement>(null);
577
+ const map = useRef<mapboxgl.Map | null>(null);
578
+ const markersRef = useRef<mapboxgl.Marker[]>([]);
579
+
580
+ useEffect(() => {
581
+ if (!mapContainer.current || !MAPBOX_TOKEN) return;
582
+
583
+ map.current = new mapboxgl.Map({
584
+ container: mapContainer.current,
585
+ style: "mapbox://styles/mapbox/streets-v12",
586
+ center: [center.lng, center.lat],
587
+ zoom: zoom,
588
+ accessToken: MAPBOX_TOKEN,
589
+ });
590
+
591
+ map.current.addControl(new mapboxgl.NavigationControl(), "top-right");
592
+
593
+ return () => {
594
+ map.current?.remove();
595
+ };
596
+ }, []);
597
+
598
+ useEffect(() => {
599
+ if (!map.current) return;
600
+
601
+ map.current.flyTo({
602
+ center: [center.lng, center.lat],
603
+ zoom: zoom,
604
+ });
605
+ }, [center, zoom]);
606
+
607
+ useEffect(() => {
608
+ if (!map.current) return;
609
+
610
+ markersRef.current.forEach((marker) => marker.remove());
611
+ markersRef.current = [];
612
+
613
+ screens.forEach((screen) => {
614
+ if (screen.latitude && screen.longitude) {
615
+ const lat = parseFloat(screen.latitude);
616
+ const lng = parseFloat(screen.longitude);
617
+
618
+ if (!isNaN(lat) && !isNaN(lng)) {
619
+ const isSelected = selectedIds.has(screen.id);
620
+ const el = document.createElement("div");
621
+ el.className = `flex items-center justify-center w-7 h-7 rounded-full shadow-lg cursor-pointer transition-all ${
622
+ isSelected
623
+ ? "bg-primary text-primary-foreground scale-100"
624
+ : "bg-muted text-muted-foreground scale-90 opacity-70"
625
+ }`;
626
+ el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>';
627
+ el.onclick = () => onScreenClick(screen.id);
628
+
629
+ const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(`
630
+ <div class="p-2">
631
+ <div class="font-medium text-sm">${screen.name}</div>
632
+ <div class="text-xs text-gray-500">${screen.city || ''}, ${screen.country || ''}</div>
633
+ <div class="text-xs mt-1">CPM: $${screen.cpm || '0.00'}</div>
634
+ </div>
635
+ `);
636
+
637
+ const marker = new mapboxgl.Marker(el)
638
+ .setLngLat([lng, lat])
639
+ .setPopup(popup)
640
+ .addTo(map.current!);
641
+
642
+ markersRef.current.push(marker);
643
+ }
644
+ }
645
+ });
646
+ }, [screens, selectedIds, onScreenClick]);
647
+
648
+ if (!MAPBOX_TOKEN) {
649
+ return (
650
+ <div className="h-full flex items-center justify-center bg-muted">
651
+ <div className="text-center text-muted-foreground">
652
+ <Globe className="h-12 w-12 mx-auto mb-2 opacity-50" />
653
+ <p>Map unavailable</p>
654
+ <p className="text-sm">Mapbox token not configured</p>
655
+ </div>
656
+ </div>
657
+ );
658
+ }
659
+
660
+ return <div ref={mapContainer} className="h-full w-full" />;
661
+ }
662
+
663
+ export default function LineItemForm() {
664
+ const [, setLocation] = useLocation();
665
+ const [matchNew, newParams] = useRoute("/deals/:dealId/line-items/new");
666
+ const [matchEdit, editParams] = useRoute("/deals/:dealId/line-items/:lineItemId/edit");
667
+ const { toast } = useToast();
668
+ const [advancedOpen, setAdvancedOpen] = useState(false);
669
+ const [inventoryLoading, setInventoryLoading] = useState(false);
670
+ const [fetchedInventoryCount, setFetchedInventoryCount] = useState<number | null>(null);
671
+ const [inventoryDrawerOpen, setInventoryDrawerOpen] = useState(false);
672
+ const [fetchedScreens, setFetchedScreens] = useState<Screen[]>([]);
673
+
674
+ const [advancedMapDrawerOpen, setAdvancedMapDrawerOpen] = useState(false);
675
+ const [venueTypeDrawerOpen, setVenueTypeDrawerOpen] = useState(false);
676
+ const [availabilityDrawerOpen, setAvailabilityDrawerOpen] = useState(false);
677
+ const [poiDrawerOpen, setPoiDrawerOpen] = useState(false);
678
+ const [mediaOwnerDrawerOpen, setMediaOwnerDrawerOpen] = useState(false);
679
+ const [inventoryFormatDrawerOpen, setInventoryFormatDrawerOpen] = useState(false);
680
+ const [manualInventoryDrawerOpen, setManualInventoryDrawerOpen] = useState(false);
681
+ const [activeTargetingSection, setActiveTargetingSection] = useState<string | null>(null);
682
+ const [selectedPOICategories, setSelectedPOICategories] = useState<string[]>([]);
683
+ const [selectedMediaOwners, setSelectedMediaOwners] = useState<string[]>([]);
684
+ const [currentFormSection, setCurrentFormSection] = useState<string>("Basic Details");
685
+
686
+ const targetingSectionRef = useRef<HTMLDivElement>(null);
687
+ const inventorySectionRef = useRef<HTMLDivElement>(null);
688
+ const basicSectionRef = useRef<HTMLDivElement>(null);
689
+ const scheduleSectionRef = useRef<HTMLDivElement>(null);
690
+ const budgetSectionRef = useRef<HTMLDivElement>(null);
691
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
692
+
693
+ useEffect(() => {
694
+ const handleClickOutside = (event: MouseEvent) => {
695
+ if (activeTargetingSection) {
696
+ const target = event.target as HTMLElement;
697
+ const clickedCollapsible = target.closest('[data-collapsible-section]');
698
+ const clickedSectionId = clickedCollapsible?.getAttribute('data-collapsible-section');
699
+
700
+ if (!clickedSectionId || clickedSectionId !== activeTargetingSection) {
701
+ setActiveTargetingSection(null);
702
+ }
703
+ }
704
+ };
705
+
706
+ document.addEventListener("mousedown", handleClickOutside);
707
+ return () => document.removeEventListener("mousedown", handleClickOutside);
708
+ }, [activeTargetingSection]);
709
+
710
+ useEffect(() => {
711
+ const scrollContainer = scrollContainerRef.current;
712
+ if (!scrollContainer) return;
713
+
714
+ const handleScroll = () => {
715
+ const sections = [
716
+ { ref: basicSectionRef, name: "Basic Details" },
717
+ { ref: targetingSectionRef, name: "Targeting" },
718
+ { ref: inventorySectionRef, name: "Inventory" },
719
+ { ref: scheduleSectionRef, name: "Schedule" },
720
+ { ref: budgetSectionRef, name: "Budget" },
721
+ ];
722
+
723
+ const containerRect = scrollContainer.getBoundingClientRect();
724
+ const viewportCenter = containerRect.top + containerRect.height / 3;
725
+
726
+ for (const { ref, name } of sections) {
727
+ if (ref.current) {
728
+ const rect = ref.current.getBoundingClientRect();
729
+ if (rect.top <= viewportCenter && rect.bottom >= viewportCenter) {
730
+ setCurrentFormSection(name);
731
+ break;
732
+ }
733
+ }
734
+ }
735
+ };
736
+
737
+ scrollContainer.addEventListener("scroll", handleScroll);
738
+ handleScroll();
739
+
740
+ return () => scrollContainer.removeEventListener("scroll", handleScroll);
741
+ }, []);
742
+
743
+ const isEditing = Boolean(matchEdit);
744
+ const dealId = isEditing ? editParams?.dealId : newParams?.dealId;
745
+ const lineItemId = editParams?.lineItemId;
746
+ const hasDealFromRoute = Boolean(dealId);
747
+
748
+ const { data: allDeals, isLoading: dealsLoading } = useQuery<Deal[]>({
749
+ queryKey: ["/api/deals"],
750
+ });
751
+
752
+ const { data: allLineItems, isLoading: lineItemsLoading } = useQuery<LineItem[]>({
753
+ queryKey: ["/api/line-items"],
754
+ });
755
+
756
+ const { data: existingLineItem, isLoading: lineItemLoading } = useQuery<LineItem>({
757
+ queryKey: ["/api/line-items", lineItemId],
758
+ enabled: isEditing && Boolean(lineItemId),
759
+ });
760
+
761
+ const { data: currentDeal } = useQuery<Deal>({
762
+ queryKey: ["/api/deals", dealId],
763
+ enabled: Boolean(dealId),
764
+ });
765
+
766
+ const { data: allScreens = [] } = useQuery<Screen[]>({
767
+ queryKey: ["/api/screens"],
768
+ });
769
+
770
+ const { data: dspPartners = [] } = useQuery<DspPartner[]>({
771
+ queryKey: ["/api/dsp-partners"],
772
+ });
773
+
774
+ const { data: signals = [] } = useQuery<Signal[]>({
775
+ queryKey: ["/api/signals"],
776
+ });
777
+
778
+ const isPGDeal = currentDeal?.dealType === "pg";
779
+ const isProgrammaticDeal = currentDeal?.dealType !== "traditional";
780
+ const isTraditionalDeal = currentDeal?.dealType === "traditional";
781
+ const dealBudget = currentDeal?.budget ? parseFloat(currentDeal.budget) : 0;
782
+
783
+ // Line items available for copying (exclude current line item if editing)
784
+ const lineItemsForCopy = useMemo(() => {
785
+ if (!allLineItems) return [];
786
+ return allLineItems.filter((li) => li.id !== lineItemId);
787
+ }, [allLineItems, lineItemId]);
788
+
789
+ const auctionType = useMemo(() => {
790
+ if (!currentDeal) return null;
791
+ const dealType = currentDeal.dealType;
792
+ if (dealType === 'pg' || dealType === 'pd') return 'Fixed Price';
793
+ if (dealType === 'traditional') return 'Not Applicable';
794
+ if (dealType === 'always_on' || dealType === 'pmp') return 'First Price Auction';
795
+ return null;
796
+ }, [currentDeal]);
797
+
798
+ const dealOptions: ComboboxOption[] = (allDeals ?? []).map((deal) => ({
799
+ value: deal.id,
800
+ label: deal.name,
801
+ }));
802
+
803
+ const existingLineItemOptions: ComboboxOption[] = (allLineItems ?? [])
804
+ .filter((li) => li.id !== lineItemId)
805
+ .map((li) => ({
806
+ value: li.id,
807
+ label: li.name,
808
+ }));
809
+
810
+ const form = useForm<LineItemFormData>({
811
+ resolver: zodResolver(lineItemFormSchema),
812
+ defaultValues: {
813
+ name: "",
814
+ dealId: "",
815
+ status: "draft",
816
+ copiedFromId: "",
817
+ copyFromLineItemId: "",
818
+ creativeType: "display",
819
+ priority: 5,
820
+ demographics: [],
821
+ geography: [],
822
+ selectedPOIs: [],
823
+ incomeBrackets: [],
824
+ behaviors: [],
825
+ interests: [],
826
+ inventoryClassification: [],
827
+ inventoryType: [],
828
+ inventoryFormat: [],
829
+ selectedScreens: [],
830
+ startDate: "",
831
+ endDate: "",
832
+ schedules: [],
833
+ budget: "",
834
+ pacing: "even",
835
+ trafficAllocation: 100,
836
+ customFees: [],
837
+ frequencyCapImpressions: "",
838
+ frequencyCapPeriod: undefined,
839
+ dspId: "",
840
+ dspSeatId: "",
841
+ pushToDsp: false,
842
+ creativeDuration: 10,
843
+ triggerEnabled: false,
844
+ triggerId: "",
845
+ resolution: "",
846
+ venueTypes: [],
847
+ adResolutions: [],
848
+ adDurations: [],
849
+ billable: true,
850
+ unlimitedBudget: false,
851
+ budgetConsumption: "daily",
852
+ dailyBudget: "",
853
+ currency: "USD",
854
+ automatedBidding: false,
855
+ maxBid: "",
856
+ bidType: "cpm",
857
+ },
858
+ });
859
+
860
+ const watchedSelectedPOIs = useWatch({ control: form.control, name: "selectedPOIs" });
861
+
862
+ const handleFetchInventory = useCallback(async () => {
863
+ setInventoryLoading(true);
864
+ setFetchedInventoryCount(null);
865
+
866
+ try {
867
+ const params = new URLSearchParams();
868
+ const countries = currentDeal?.countries;
869
+ if (countries && countries.length > 0) {
870
+ params.set("country", countries[0]);
871
+ }
872
+ if (currentDeal?.sspId) {
873
+ params.set("sspId", currentDeal.sspId);
874
+ }
875
+
876
+ const url = params.toString() ? `/api/screens?${params.toString()}` : "/api/screens";
877
+ const response = await fetch(url);
878
+ const screens: Screen[] = await response.json();
879
+
880
+ setFetchedScreens(screens);
881
+ setFetchedInventoryCount(screens.length);
882
+ form.setValue("selectedScreens", screens.map(s => s.id));
883
+
884
+ toast({
885
+ title: "Inventory fetched",
886
+ description: `Found ${screens.length} available screens`,
887
+ });
888
+ } catch (error) {
889
+ toast({
890
+ title: "Error",
891
+ description: "Failed to fetch inventory. Please try again.",
892
+ variant: "destructive",
893
+ });
894
+ } finally {
895
+ setInventoryLoading(false);
896
+ }
897
+ }, [currentDeal, form, toast]);
898
+
899
+ const handleToggleScreen = useCallback((screenId: string) => {
900
+ const current = form.getValues("selectedScreens") || [];
901
+ if (current.includes(screenId)) {
902
+ form.setValue("selectedScreens", current.filter(id => id !== screenId));
903
+ } else {
904
+ form.setValue("selectedScreens", [...current, screenId]);
905
+ }
906
+ }, [form]);
907
+
908
+ // Watch inventory selections and floor rate type for auto-calculation
909
+ const watchedInventoryClassification = useWatch({ control: form.control, name: "inventoryClassification" });
910
+ const watchedInventoryType = useWatch({ control: form.control, name: "inventoryType" });
911
+ const watchedInventoryFormat = useWatch({ control: form.control, name: "inventoryFormat" });
912
+ const watchedBidType = useWatch({ control: form.control, name: "bidType" });
913
+ const watchedSelectedScreens = useWatch({ control: form.control, name: "selectedScreens" });
914
+ const watchedCreativeDuration = useWatch({ control: form.control, name: "creativeDuration" });
915
+
916
+ const selectedScreenIds = useMemo(() => new Set(watchedSelectedScreens || []), [watchedSelectedScreens]);
917
+
918
+ const primaryCountry = (currentDeal?.countries && currentDeal.countries.length > 0) ? currentDeal.countries[0] : "Malaysia";
919
+ const countryName = COUNTRIES.find(c => c.code === primaryCountry)?.name || "Malaysia";
920
+ const mapCenter = COUNTRY_CENTERS[countryName] || COUNTRY_CENTERS["Malaysia"];
921
+
922
+ const { fields: feeFields, append: appendFee, remove: removeFee } = useFieldArray({
923
+ control: form.control,
924
+ name: "customFees",
925
+ });
926
+
927
+ // Watch additional values for forecast calculations
928
+ const watchedStartDate = useWatch({ control: form.control, name: "startDate" });
929
+ const watchedEndDate = useWatch({ control: form.control, name: "endDate" });
930
+ const watchedMaxBid = useWatch({ control: form.control, name: "maxBid" });
931
+ const watchedBudget = useWatch({ control: form.control, name: "budget" });
932
+ const watchedCustomFees = useWatch({ control: form.control, name: "customFees" });
933
+ const watchedBillable = useWatch({ control: form.control, name: "billable" });
934
+ const watchedUnlimitedBudget = useWatch({ control: form.control, name: "unlimitedBudget" });
935
+ const watchedAutomatedBidding = useWatch({ control: form.control, name: "automatedBidding" });
936
+ const watchedGeography = useWatch({ control: form.control, name: "geography" });
937
+ const watchedDemographics = useWatch({ control: form.control, name: "demographics" });
938
+ const watchedVenueTypes = useWatch({ control: form.control, name: "venueTypes" });
939
+
940
+ // Calculate suggested max bid based on selected screens' CPM values
941
+ const suggestedMaxBid = useMemo(() => {
942
+ const selectedScreenIds = watchedSelectedScreens || [];
943
+ const bidType = watchedBidType || "cpm";
944
+
945
+ if (selectedScreenIds.length === 0) {
946
+ // Fallback to inventory category rates if no screens selected
947
+ const allSelections = [
948
+ ...(watchedInventoryClassification || []),
949
+ ...(watchedInventoryType || []),
950
+ ...(watchedInventoryFormat || []),
951
+ ];
952
+
953
+ if (allSelections.length === 0) return null;
954
+
955
+ const rates: number[] = [];
956
+ allSelections.forEach((selection) => {
957
+ const rate = INVENTORY_RATES[selection];
958
+ if (rate) {
959
+ rates.push(bidType === "cpm" ? rate.cpm : rate.cps);
960
+ }
961
+ });
962
+
963
+ if (rates.length === 0) return null;
964
+ const average = rates.reduce((sum, r) => sum + r, 0) / rates.length;
965
+ return average.toFixed(2);
966
+ }
967
+
968
+ // Calculate average CPM from selected screens
969
+ const selectedScreens = allScreens.filter(s => selectedScreenIds.includes(s.id));
970
+ if (selectedScreens.length === 0) return null;
971
+
972
+ const validCpms = selectedScreens
973
+ .map(s => s.cpm ? parseFloat(s.cpm) : null)
974
+ .filter((cpm): cpm is number => cpm !== null && !isNaN(cpm));
975
+
976
+ if (validCpms.length === 0) return null;
977
+
978
+ const avgCpm = validCpms.reduce((sum, cpm) => sum + cpm, 0) / validCpms.length;
979
+
980
+ if (bidType === "cpm") {
981
+ return avgCpm.toFixed(2);
982
+ } else {
983
+ // CPS: derive from CPM (CPM / 1000 / avg_duration)
984
+ const duration = watchedCreativeDuration || 10;
985
+ const cps = avgCpm / 1000 / duration;
986
+ return cps.toFixed(4);
987
+ }
988
+ }, [watchedSelectedScreens, allScreens, watchedInventoryClassification, watchedInventoryType, watchedInventoryFormat, watchedBidType, watchedCreativeDuration]);
989
+
990
+ // Check if current max bid differs from suggestion (using numeric comparison)
991
+ const maxBidDiffersFromSuggestion = useMemo(() => {
992
+ if (!suggestedMaxBid || !watchedMaxBid) return false;
993
+ const currentValue = parseFloat(String(watchedMaxBid));
994
+ const suggestedValue = parseFloat(suggestedMaxBid);
995
+ if (isNaN(currentValue) || isNaN(suggestedValue)) return false;
996
+ // Use a small tolerance for floating point comparison
997
+ return Math.abs(currentValue - suggestedValue) > 0.001;
998
+ }, [suggestedMaxBid, watchedMaxBid]);
999
+
1000
+ const applyMaxBidSuggestion = () => {
1001
+ if (suggestedMaxBid) {
1002
+ form.setValue("maxBid", suggestedMaxBid);
1003
+ }
1004
+ };
1005
+
1006
+ // Calculate forecast based on form values
1007
+ const forecast = useMemo(() => {
1008
+ const allInventorySelections = [
1009
+ ...(watchedInventoryClassification || []),
1010
+ ...(watchedInventoryType || []),
1011
+ ...(watchedInventoryFormat || []),
1012
+ ];
1013
+ const selectedScreenIds = watchedSelectedScreens || [];
1014
+
1015
+ // Use selected screens count if available, otherwise use inventory selections
1016
+ const inventoryCount = selectedScreenIds.length > 0
1017
+ ? selectedScreenIds.length
1018
+ : allInventorySelections.length;
1019
+ const startDate = watchedStartDate ? new Date(watchedStartDate) : null;
1020
+ const endDate = watchedEndDate ? new Date(watchedEndDate) : null;
1021
+ const maxBid = watchedMaxBid ? parseFloat(watchedMaxBid) : 0;
1022
+ const lineItemBudget = watchedBudget ? parseFloat(watchedBudget) : 0;
1023
+ const creativeDuration = watchedCreativeDuration || 10;
1024
+ const bidType = watchedBidType || "cpm";
1025
+
1026
+ // Check if we have enough data to calculate forecast
1027
+ if (inventoryCount === 0 || !startDate || !endDate || startDate > endDate) {
1028
+ return null;
1029
+ }
1030
+
1031
+ // Calculate days in range
1032
+ const timeDiff = endDate.getTime() - startDate.getTime();
1033
+ const daysInRange = Math.max(1, Math.ceil(timeDiff / (1000 * 60 * 60 * 24)) + 1);
1034
+
1035
+ // Mock logic: estimated impressions = inventory selections × 10000 × days
1036
+ const estimatedImpressions = inventoryCount * 10000 * daysInRange;
1037
+
1038
+ // Calculate total cost based on bid type
1039
+ let totalCost = 0;
1040
+ if (maxBid > 0) {
1041
+ if (bidType === "cpm") {
1042
+ // CPM: cost = (impressions / 1000) * max_bid
1043
+ totalCost = (estimatedImpressions / 1000) * maxBid;
1044
+ } else {
1045
+ // CPS: cost = impressions * creative_duration * max_bid
1046
+ totalCost = estimatedImpressions * creativeDuration * maxBid;
1047
+ }
1048
+ }
1049
+
1050
+ // Media Cost is the base cost from floor rate
1051
+ const mediaCost = totalCost;
1052
+
1053
+ // Calculate custom fees total
1054
+ let customFeesTotal = 0;
1055
+ const customFeesList = watchedCustomFees || [];
1056
+ customFeesList.forEach((fee: { amount: number; type: string }) => {
1057
+ if (fee.type === "fixed") {
1058
+ customFeesTotal += fee.amount;
1059
+ } else if (fee.type === "percentage") {
1060
+ customFeesTotal += mediaCost * (fee.amount / 100);
1061
+ }
1062
+ });
1063
+
1064
+ // Platform fee (5% of media cost as example)
1065
+ const platformFeePercent = 5;
1066
+ const platformFee = mediaCost * (platformFeePercent / 100);
1067
+
1068
+ // Grand total = Media Cost + Custom Fees + Platform Fee
1069
+ const grandTotal = mediaCost + customFeesTotal + platformFee;
1070
+
1071
+ // Calculate cost efficiency (effective rate)
1072
+ const effectiveCPM = estimatedImpressions > 0 && totalCost > 0
1073
+ ? (totalCost / estimatedImpressions) * 1000
1074
+ : 0;
1075
+ const effectiveCPS = estimatedImpressions > 0 && totalCost > 0
1076
+ ? totalCost / estimatedImpressions
1077
+ : 0;
1078
+
1079
+ // Calculate budget utilization if budget is set
1080
+ const budgetUtilization = lineItemBudget > 0 && grandTotal > 0
1081
+ ? Math.min(100, (grandTotal / lineItemBudget) * 100)
1082
+ : null;
1083
+
1084
+ // Check if cost exceeds deal budget
1085
+ const exceedsDealBudget = dealBudget > 0 && grandTotal > dealBudget;
1086
+ const exceedsLineItemBudget = lineItemBudget > 0 && grandTotal > lineItemBudget;
1087
+
1088
+ return {
1089
+ estimatedImpressions,
1090
+ estimatedSpend: grandTotal,
1091
+ totalCost: grandTotal,
1092
+ mediaCost,
1093
+ customFeesTotal,
1094
+ platformFee,
1095
+ platformFeePercent,
1096
+ hasCustomFees: customFeesList.length > 0 && customFeesTotal > 0,
1097
+ effectiveCPM,
1098
+ effectiveCPS,
1099
+ daysInRange,
1100
+ budgetUtilization,
1101
+ hasBudget: lineItemBudget > 0,
1102
+ hasMaxBid: maxBid > 0,
1103
+ dealBudget,
1104
+ lineItemBudget,
1105
+ exceedsDealBudget,
1106
+ exceedsLineItemBudget,
1107
+ creativeDuration,
1108
+ };
1109
+ }, [
1110
+ watchedInventoryClassification,
1111
+ watchedInventoryType,
1112
+ watchedInventoryFormat,
1113
+ watchedSelectedScreens,
1114
+ watchedStartDate,
1115
+ watchedEndDate,
1116
+ watchedMaxBid,
1117
+ watchedBidType,
1118
+ watchedBudget,
1119
+ watchedCreativeDuration,
1120
+ watchedCustomFees,
1121
+ dealBudget,
1122
+ ]);
1123
+
1124
+ const handleCopyLineItem = (lineItemId: string) => {
1125
+ const sourceLineItem = allLineItems?.find((li) => li.id === lineItemId);
1126
+ if (sourceLineItem) {
1127
+ const targeting = sourceLineItem.targeting as Record<string, string[]> | null;
1128
+ const inventoryFilters = sourceLineItem.inventoryFilters as Record<string, string[]> | null;
1129
+ const customFees = sourceLineItem.customFees as Array<{ name: string; amount: number; type: "fixed" | "percentage"; hidden?: boolean }> | null;
1130
+ const frequencyCap = sourceLineItem.frequencyCap as { impressions: number; period: string } | null;
1131
+
1132
+ form.reset({
1133
+ name: form.getValues("name"),
1134
+ dealId: form.getValues("dealId"),
1135
+ status: (sourceLineItem.status as "active" | "paused" | "draft") ?? "draft",
1136
+ copiedFromId: lineItemId,
1137
+ copyFromLineItemId: lineItemId,
1138
+ creativeType: (sourceLineItem.creativeType as "display" | "video" | "audio") ?? "display",
1139
+ priority: sourceLineItem.priority ?? 5,
1140
+ demographics: targeting?.demographics ?? [],
1141
+ geography: targeting?.geography ?? [],
1142
+ selectedPOIs: targeting?.selectedPOIs ?? [],
1143
+ incomeBrackets: targeting?.incomeBrackets ?? [],
1144
+ behaviors: targeting?.behaviors ?? [],
1145
+ interests: targeting?.interests ?? [],
1146
+ inventoryClassification: inventoryFilters?.classification ?? [],
1147
+ inventoryType: inventoryFilters?.type ?? [],
1148
+ inventoryFormat: inventoryFilters?.format ?? [],
1149
+ startDate: sourceLineItem.startDate ?? "",
1150
+ endDate: sourceLineItem.endDate ?? "",
1151
+ schedules: (sourceLineItem.schedules as Schedule[]) ?? [],
1152
+ budget: sourceLineItem.budget ?? "",
1153
+ pacing: (sourceLineItem.pacing as "asap" | "even" | "front_loaded") ?? "even",
1154
+ trafficAllocation: sourceLineItem.trafficAllocation ?? 100,
1155
+ customFees: customFees ?? [],
1156
+ frequencyCapImpressions: frequencyCap?.impressions?.toString() ?? "",
1157
+ frequencyCapPeriod: frequencyCap?.period as "hour" | "day" | "week" | "month" | "lifetime" | undefined,
1158
+ triggerEnabled: sourceLineItem.triggerEnabled ?? false,
1159
+ triggerId: sourceLineItem.triggerId ?? "",
1160
+ resolution: sourceLineItem.resolution ?? "",
1161
+ venueTypes: form.getValues("venueTypes") ?? [],
1162
+ adResolutions: form.getValues("adResolutions") ?? [],
1163
+ adDurations: form.getValues("adDurations") ?? [],
1164
+ billable: form.getValues("billable") ?? true,
1165
+ unlimitedBudget: form.getValues("unlimitedBudget") ?? false,
1166
+ budgetConsumption: form.getValues("budgetConsumption") ?? "daily",
1167
+ dailyBudget: form.getValues("dailyBudget") ?? "",
1168
+ currency: currentDeal?.currency || "USD",
1169
+ automatedBidding: form.getValues("automatedBidding") ?? false,
1170
+ maxBid: form.getValues("maxBid") ?? "",
1171
+ bidType: form.getValues("bidType") ?? "cpm",
1172
+ });
1173
+
1174
+ toast({
1175
+ title: "Line item copied",
1176
+ description: `Settings copied from "${sourceLineItem.name}"`,
1177
+ });
1178
+ }
1179
+ };
1180
+
1181
+ useEffect(() => {
1182
+ if (existingLineItem) {
1183
+ const targeting = existingLineItem.targeting as Record<string, string[]> | null;
1184
+ const inventoryFilters = existingLineItem.inventoryFilters as Record<string, string[]> | null;
1185
+ const customFees = existingLineItem.customFees as Array<{ name: string; amount: number; type: "fixed" | "percentage"; hidden?: boolean }> | null;
1186
+ const frequencyCap = existingLineItem.frequencyCap as { impressions: number; period: string } | null;
1187
+
1188
+ form.reset({
1189
+ name: existingLineItem.name,
1190
+ dealId: existingLineItem.dealId ?? "",
1191
+ status: (existingLineItem.status as "active" | "paused" | "draft") ?? "draft",
1192
+ copiedFromId: existingLineItem.copiedFromId ?? "",
1193
+ creativeType: (existingLineItem.creativeType as "display" | "video" | "audio") ?? "display",
1194
+ priority: existingLineItem.priority ?? 5,
1195
+ demographics: targeting?.demographics ?? [],
1196
+ geography: targeting?.geography ?? [],
1197
+ selectedPOIs: targeting?.selectedPOIs ?? [],
1198
+ incomeBrackets: targeting?.incomeBrackets ?? [],
1199
+ behaviors: targeting?.behaviors ?? [],
1200
+ interests: targeting?.interests ?? [],
1201
+ inventoryClassification: inventoryFilters?.classification ?? [],
1202
+ inventoryType: inventoryFilters?.type ?? [],
1203
+ inventoryFormat: inventoryFilters?.format ?? [],
1204
+ startDate: existingLineItem.startDate ?? "",
1205
+ endDate: existingLineItem.endDate ?? "",
1206
+ schedules: (existingLineItem.schedules as Schedule[]) ?? [],
1207
+ budget: existingLineItem.budget ?? "",
1208
+ pacing: (existingLineItem.pacing as "asap" | "even" | "front_loaded") ?? "even",
1209
+ trafficAllocation: existingLineItem.trafficAllocation ?? 100,
1210
+ customFees: customFees ?? [],
1211
+ frequencyCapImpressions: frequencyCap?.impressions?.toString() ?? "",
1212
+ frequencyCapPeriod: frequencyCap?.period as "hour" | "day" | "week" | "month" | "lifetime" | undefined,
1213
+ dspId: existingLineItem.dspId ?? "",
1214
+ dspSeatId: existingLineItem.dspSeatId ?? "",
1215
+ pushToDsp: existingLineItem.pushToDsp ?? false,
1216
+ creativeDuration: existingLineItem.creativeDuration ?? 10,
1217
+ triggerEnabled: existingLineItem.triggerEnabled ?? false,
1218
+ triggerId: existingLineItem.triggerId ?? "",
1219
+ resolution: existingLineItem.resolution ?? "",
1220
+ });
1221
+ } else if (dealId && !isEditing) {
1222
+ form.setValue("dealId", dealId);
1223
+ // Set pushToDsp to true by default for programmatic deals
1224
+ if (isProgrammaticDeal) {
1225
+ form.setValue("pushToDsp", true);
1226
+ }
1227
+ }
1228
+ }, [existingLineItem, form, dealId, isEditing, isProgrammaticDeal]);
1229
+
1230
+ const createMutation = useMutation({
1231
+ mutationFn: async (data: LineItemFormData) => {
1232
+ return apiRequest("POST", "/api/line-items", {
1233
+ name: data.name,
1234
+ dealId: data.dealId,
1235
+ status: data.status,
1236
+ copiedFromId: data.copiedFromId || undefined,
1237
+ creativeType: data.creativeType,
1238
+ priority: data.priority,
1239
+ targeting: {
1240
+ demographics: data.demographics,
1241
+ geography: data.geography,
1242
+ selectedPOIs: data.selectedPOIs,
1243
+ incomeBrackets: data.incomeBrackets,
1244
+ behaviors: data.behaviors,
1245
+ interests: data.interests,
1246
+ },
1247
+ inventoryFilters: {
1248
+ classification: data.inventoryClassification,
1249
+ type: data.inventoryType,
1250
+ format: data.inventoryFormat,
1251
+ },
1252
+ selectedScreens: data.selectedScreens,
1253
+ startDate: data.startDate || undefined,
1254
+ endDate: data.endDate || undefined,
1255
+ schedules: data.schedules.length > 0 ? data.schedules : undefined,
1256
+ budget: data.budget || undefined,
1257
+ pacing: data.pacing,
1258
+ trafficAllocation: data.trafficAllocation,
1259
+ customFees: data.customFees.length > 0 ? data.customFees : undefined,
1260
+ frequencyCap: data.frequencyCapImpressions && data.frequencyCapPeriod
1261
+ ? { impressions: parseInt(data.frequencyCapImpressions), period: data.frequencyCapPeriod }
1262
+ : undefined,
1263
+ dspId: data.dspId || undefined,
1264
+ dspSeatId: data.dspSeatId || undefined,
1265
+ pushToDsp: data.pushToDsp,
1266
+ creativeDuration: data.creativeDuration,
1267
+ triggerEnabled: data.triggerEnabled,
1268
+ triggerId: data.triggerId || undefined,
1269
+ resolution: data.resolution,
1270
+ });
1271
+ },
1272
+ onSuccess: () => {
1273
+ queryClient.invalidateQueries({ queryKey: ["/api/line-items"] });
1274
+ toast({
1275
+ title: "Line item created",
1276
+ description: "Your line item has been created successfully.",
1277
+ });
1278
+ setLocation(`/deals/${dealId}`);
1279
+ },
1280
+ onError: () => {
1281
+ toast({
1282
+ title: "Error",
1283
+ description: "Failed to create line item. Please try again.",
1284
+ variant: "destructive",
1285
+ });
1286
+ },
1287
+ });
1288
+
1289
+ const updateMutation = useMutation({
1290
+ mutationFn: async (data: LineItemFormData) => {
1291
+ return apiRequest("PATCH", `/api/line-items/${lineItemId}`, {
1292
+ name: data.name,
1293
+ dealId: data.dealId,
1294
+ status: data.status,
1295
+ copiedFromId: data.copiedFromId || undefined,
1296
+ creativeType: data.creativeType,
1297
+ priority: data.priority,
1298
+ targeting: {
1299
+ demographics: data.demographics,
1300
+ geography: data.geography,
1301
+ selectedPOIs: data.selectedPOIs,
1302
+ incomeBrackets: data.incomeBrackets,
1303
+ behaviors: data.behaviors,
1304
+ interests: data.interests,
1305
+ },
1306
+ inventoryFilters: {
1307
+ classification: data.inventoryClassification,
1308
+ type: data.inventoryType,
1309
+ format: data.inventoryFormat,
1310
+ },
1311
+ selectedScreens: data.selectedScreens,
1312
+ startDate: data.startDate || undefined,
1313
+ endDate: data.endDate || undefined,
1314
+ schedules: data.schedules.length > 0 ? data.schedules : undefined,
1315
+ budget: data.budget || undefined,
1316
+ pacing: data.pacing,
1317
+ trafficAllocation: data.trafficAllocation,
1318
+ customFees: data.customFees.length > 0 ? data.customFees : undefined,
1319
+ frequencyCap: data.frequencyCapImpressions && data.frequencyCapPeriod
1320
+ ? { impressions: parseInt(data.frequencyCapImpressions), period: data.frequencyCapPeriod }
1321
+ : undefined,
1322
+ dspId: data.dspId || undefined,
1323
+ dspSeatId: data.dspSeatId || undefined,
1324
+ pushToDsp: data.pushToDsp,
1325
+ creativeDuration: data.creativeDuration,
1326
+ triggerEnabled: data.triggerEnabled,
1327
+ triggerId: data.triggerId || undefined,
1328
+ resolution: data.resolution,
1329
+ });
1330
+ },
1331
+ onSuccess: () => {
1332
+ queryClient.invalidateQueries({ queryKey: ["/api/line-items"] });
1333
+ queryClient.invalidateQueries({ queryKey: ["/api/line-items", lineItemId] });
1334
+ toast({
1335
+ title: "Line item updated",
1336
+ description: "Your line item has been updated successfully.",
1337
+ });
1338
+ setLocation(`/deals/${dealId}`);
1339
+ },
1340
+ onError: () => {
1341
+ toast({
1342
+ title: "Error",
1343
+ description: "Failed to update line item. Please try again.",
1344
+ variant: "destructive",
1345
+ });
1346
+ },
1347
+ });
1348
+
1349
+ const onSubmit = (data: LineItemFormData) => {
1350
+ if (isEditing) {
1351
+ updateMutation.mutate(data);
1352
+ } else {
1353
+ createMutation.mutate(data);
1354
+ }
1355
+ };
1356
+
1357
+ const handleCancel = () => {
1358
+ setLocation(`/deals/${dealId}`);
1359
+ };
1360
+
1361
+ const isSubmitting = createMutation.isPending || updateMutation.isPending;
1362
+ const isLoadingData = isEditing && lineItemLoading;
1363
+
1364
+ if (isLoadingData) {
1365
+ return (
1366
+ <div className="flex flex-col gap-6 p-6">
1367
+ <PageHeader
1368
+ title={isEditing ? "Edit Line Item" : "New Line Item"}
1369
+ description={isEditing ? "Update line item details" : "Create a new line item"}
1370
+ />
1371
+ <Card>
1372
+ <CardContent className="pt-6 space-y-6">
1373
+ <Skeleton className="h-10 w-full" data-testid="skeleton-name" />
1374
+ <Skeleton className="h-10 w-full" data-testid="skeleton-status" />
1375
+ <Skeleton className="h-10 w-full" data-testid="skeleton-creative-type" />
1376
+ <Skeleton className="h-16 w-full" data-testid="skeleton-priority" />
1377
+ <div className="grid grid-cols-2 gap-4">
1378
+ <Skeleton className="h-10 w-full" data-testid="skeleton-start-date" />
1379
+ <Skeleton className="h-10 w-full" data-testid="skeleton-end-date" />
1380
+ </div>
1381
+ <Skeleton className="h-10 w-full" data-testid="skeleton-budget" />
1382
+ </CardContent>
1383
+ </Card>
1384
+ </div>
1385
+ );
1386
+ }
1387
+
1388
+ const incomeBracketLabels = INCOME_BRACKETS.map((b) => b.label);
1389
+ const behaviorLabels = BEHAVIORS.map((b) => b.label);
1390
+ const demographicOptions = [...AGE_GROUPS, ...GENDERS];
1391
+
1392
+ // Targeting summary row component
1393
+ const TargetingSummaryRow = ({
1394
+ label,
1395
+ count,
1396
+ onClick,
1397
+ icon: Icon
1398
+ }: {
1399
+ label: string;
1400
+ count: number;
1401
+ onClick: () => void;
1402
+ icon: React.ElementType;
1403
+ }) => (
1404
+ <div
1405
+ className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
1406
+ onClick={onClick}
1407
+ data-testid={`targeting-row-${label.toLowerCase().replace(/\s+/g, '-')}`}
1408
+ >
1409
+ <div className="flex items-center gap-3">
1410
+ <Icon className="h-4 w-4 text-muted-foreground" />
1411
+ <span className="text-sm">{label}</span>
1412
+ </div>
1413
+ <div className="flex items-center gap-2">
1414
+ <span className="text-sm text-muted-foreground">
1415
+ {count > 0 ? `${count} ${label} are selected` : `0 ${label} are selected`}
1416
+ </span>
1417
+ <Check className={`h-4 w-4 ${count > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
1418
+ </div>
1419
+ </div>
1420
+ );
1421
+
1422
+ return (
1423
+ <div className="relative flex flex-col h-full overflow-hidden">
1424
+ <div ref={scrollContainerRef} className="flex-1 overflow-auto p-6 pb-20">
1425
+ <div className="flex flex-col gap-6">
1426
+ <div className="flex items-center justify-between">
1427
+ <div className="flex items-center gap-4">
1428
+ <h1 className="text-2xl font-bold" data-testid="text-page-title">
1429
+ {isEditing ? "Edit Line Item" : "New Line Item"}
1430
+ </h1>
1431
+ </div>
1432
+ <Button
1433
+ variant="outline"
1434
+ onClick={handleCancel}
1435
+ data-testid="button-back-deal"
1436
+ >
1437
+ <ArrowLeft className="h-4 w-4 mr-2" />
1438
+ Back
1439
+ </Button>
1440
+ </div>
1441
+
1442
+ <div className="mt-6">
1443
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
1444
+ <div className="lg:col-span-2">
1445
+ <Form {...form}>
1446
+ <form id="line-item-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
1447
+
1448
+ {/* Line Item Name and Status */}
1449
+ <Card ref={basicSectionRef as any}>
1450
+ <CardContent className="pt-6">
1451
+ <div className="flex items-center gap-4 flex-wrap">
1452
+ <FormField
1453
+ control={form.control}
1454
+ name="name"
1455
+ render={({ field }) => (
1456
+ <FormItem className="flex-1 min-w-[200px]">
1457
+ <FormLabel>Line Item Name <span className="text-destructive">*</span></FormLabel>
1458
+ <FormControl>
1459
+ <Input
1460
+ placeholder="Enter line item name"
1461
+ {...field}
1462
+ data-testid="input-line-item-name"
1463
+ />
1464
+ </FormControl>
1465
+ <FormMessage />
1466
+ </FormItem>
1467
+ )}
1468
+ />
1469
+
1470
+ <FormField
1471
+ control={form.control}
1472
+ name="status"
1473
+ render={({ field }) => (
1474
+ <FormItem className="w-32">
1475
+ <FormLabel>Status</FormLabel>
1476
+ <Select onValueChange={field.onChange} value={field.value}>
1477
+ <FormControl>
1478
+ <SelectTrigger data-testid="select-status">
1479
+ <SelectValue placeholder="Select status" />
1480
+ </SelectTrigger>
1481
+ </FormControl>
1482
+ <SelectContent>
1483
+ <SelectItem value="active">Active</SelectItem>
1484
+ <SelectItem value="paused">Paused</SelectItem>
1485
+ <SelectItem value="draft">Draft</SelectItem>
1486
+ </SelectContent>
1487
+ </Select>
1488
+ <FormMessage />
1489
+ </FormItem>
1490
+ )}
1491
+ />
1492
+
1493
+ {!isPGDeal && (
1494
+ <FormField
1495
+ control={form.control}
1496
+ name="copyFromLineItemId"
1497
+ render={({ field }) => (
1498
+ <FormItem className="min-w-[200px]">
1499
+ <FormLabel>Copy from Line Item</FormLabel>
1500
+ <FormControl>
1501
+ <SearchableCombobox
1502
+ options={lineItemsForCopy.map((li: LineItem) => ({
1503
+ value: li.id,
1504
+ label: li.name,
1505
+ description: `Priority: ${li.priority}`,
1506
+ }))}
1507
+ value={field.value || ""}
1508
+ onValueChange={(value) => {
1509
+ field.onChange(value);
1510
+ if (value) {
1511
+ handleCopyLineItem(value);
1512
+ }
1513
+ }}
1514
+ placeholder="Select to copy..."
1515
+ searchPlaceholder="Search line items..."
1516
+ emptyMessage="No line items found."
1517
+ data-testid="combobox-copy-line-item"
1518
+ />
1519
+ </FormControl>
1520
+ </FormItem>
1521
+ )}
1522
+ />
1523
+ )}
1524
+ </div>
1525
+
1526
+ {!hasDealFromRoute && (
1527
+ <FormField
1528
+ control={form.control}
1529
+ name="dealId"
1530
+ render={({ field }) => (
1531
+ <FormItem className="mt-4">
1532
+ <FormLabel>Deal</FormLabel>
1533
+ <FormControl>
1534
+ {dealsLoading ? (
1535
+ <Skeleton className="h-10 w-full" data-testid="skeleton-deal-loading" />
1536
+ ) : (
1537
+ <SearchableCombobox
1538
+ options={dealOptions}
1539
+ value={field.value}
1540
+ onValueChange={field.onChange}
1541
+ placeholder="Select a deal..."
1542
+ searchPlaceholder="Search deals..."
1543
+ emptyMessage="No deals found."
1544
+ data-testid="combobox-deal"
1545
+ />
1546
+ )}
1547
+ </FormControl>
1548
+ <FormMessage />
1549
+ </FormItem>
1550
+ )}
1551
+ />
1552
+ )}
1553
+ </CardContent>
1554
+ </Card>
1555
+
1556
+ <Card>
1557
+ <CardHeader>
1558
+ <CardTitle>Creative</CardTitle>
1559
+ </CardHeader>
1560
+ <CardContent className="space-y-4">
1561
+ <FormField
1562
+ control={form.control}
1563
+ name="creativeType"
1564
+ render={({ field }) => (
1565
+ <FormItem>
1566
+ <FormLabel>Creative Type <span className="text-destructive">*</span></FormLabel>
1567
+ <Select onValueChange={field.onChange} value={field.value}>
1568
+ <FormControl>
1569
+ <SelectTrigger data-testid="select-creative-type">
1570
+ <SelectValue placeholder="Select creative type" />
1571
+ </SelectTrigger>
1572
+ </FormControl>
1573
+ <SelectContent>
1574
+ {CREATIVE_TYPES.map((type) => (
1575
+ <SelectItem key={type.value} value={type.value}>
1576
+ {type.label}
1577
+ </SelectItem>
1578
+ ))}
1579
+ </SelectContent>
1580
+ </Select>
1581
+ <FormMessage />
1582
+ </FormItem>
1583
+ )}
1584
+ />
1585
+
1586
+ <FormField
1587
+ control={form.control}
1588
+ name="priority"
1589
+ render={({ field }) => (
1590
+ <FormItem>
1591
+ <div className="flex items-center justify-between">
1592
+ <FormLabel>Priority</FormLabel>
1593
+ <span className="text-sm font-medium">
1594
+ {field.value} {field.value === 1 ? "(Highest)" : field.value === 10 ? "(Lowest)" : ""}
1595
+ </span>
1596
+ </div>
1597
+ <FormControl>
1598
+ <Slider
1599
+ value={[field.value]}
1600
+ onValueChange={([val]) => field.onChange(val)}
1601
+ min={1}
1602
+ max={10}
1603
+ step={1}
1604
+ className="w-full"
1605
+ data-testid="slider-priority"
1606
+ />
1607
+ </FormControl>
1608
+ <div className="flex justify-between text-xs text-muted-foreground">
1609
+ <span>1 (Highest)</span>
1610
+ <span>5</span>
1611
+ <span>10 (Lowest)</span>
1612
+ </div>
1613
+ <FormDescription>
1614
+ Lower number means higher priority for ad serving
1615
+ </FormDescription>
1616
+ <FormMessage />
1617
+ </FormItem>
1618
+ )}
1619
+ />
1620
+ </CardContent>
1621
+ </Card>
1622
+
1623
+ {/* Flight Dates Section - Before Targeting */}
1624
+ <Card ref={scheduleSectionRef as any}>
1625
+ <CardHeader className="pb-3">
1626
+ <div className="flex items-center gap-2">
1627
+ <CalendarRange className="h-4 w-4 text-muted-foreground" />
1628
+ <CardTitle className="text-base">Flight Dates</CardTitle>
1629
+ </div>
1630
+ </CardHeader>
1631
+ <CardContent className="space-y-4">
1632
+ {/* Quick Date Range Selection */}
1633
+ <div className="flex flex-wrap gap-2">
1634
+ <span className="text-sm text-muted-foreground mr-2 flex items-center">Quick select:</span>
1635
+ {[
1636
+ { label: "7 days", days: 7 },
1637
+ { label: "28 days", days: 28 },
1638
+ { label: "30 days", days: 30 },
1639
+ { label: "45 days", days: 45 },
1640
+ { label: "60 days", days: 60 },
1641
+ ].map(({ label, days }) => (
1642
+ <Button
1643
+ key={days}
1644
+ type="button"
1645
+ variant="outline"
1646
+ size="sm"
1647
+ onClick={() => {
1648
+ const today = new Date();
1649
+ const endDate = new Date(today);
1650
+ endDate.setDate(today.getDate() + days);
1651
+ form.setValue("startDate", format(today, "yyyy-MM-dd"));
1652
+ form.setValue("endDate", format(endDate, "yyyy-MM-dd"));
1653
+ }}
1654
+ data-testid={`button-quick-${days}-days`}
1655
+ >
1656
+ Next {label}
1657
+ </Button>
1658
+ ))}
1659
+ </div>
1660
+
1661
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
1662
+ <FormField
1663
+ control={form.control}
1664
+ name="startDate"
1665
+ render={({ field }) => (
1666
+ <DatePickerField
1667
+ value={field.value}
1668
+ onChange={field.onChange}
1669
+ label="Start Date"
1670
+ testId="input-start-date"
1671
+ required
1672
+ />
1673
+ )}
1674
+ />
1675
+
1676
+ <FormField
1677
+ control={form.control}
1678
+ name="endDate"
1679
+ render={({ field }) => (
1680
+ <DatePickerField
1681
+ value={field.value}
1682
+ onChange={field.onChange}
1683
+ label="End Date"
1684
+ testId="input-end-date"
1685
+ required
1686
+ />
1687
+ )}
1688
+ />
1689
+ </div>
1690
+ </CardContent>
1691
+ </Card>
1692
+
1693
+ {/* Budget Options Section */}
1694
+ <Card ref={budgetSectionRef as any}>
1695
+ <CardHeader className="pb-3">
1696
+ <div className="flex items-center gap-2">
1697
+ <CircleDollarSign className="h-4 w-4 text-muted-foreground" />
1698
+ <CardTitle className="text-base">Budget Options</CardTitle>
1699
+ </div>
1700
+ </CardHeader>
1701
+ <CardContent className="space-y-4">
1702
+ <FormField
1703
+ control={form.control}
1704
+ name="unlimitedBudget"
1705
+ render={({ field }) => (
1706
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
1707
+ <div className="space-y-0.5">
1708
+ <FormLabel>Unlimited Budget</FormLabel>
1709
+ <FormDescription>
1710
+ When enabled, line item will not have a budget cap
1711
+ </FormDescription>
1712
+ </div>
1713
+ <FormControl>
1714
+ <Switch
1715
+ checked={field.value || false}
1716
+ onCheckedChange={field.onChange}
1717
+ data-testid="switch-unlimited-budget"
1718
+ />
1719
+ </FormControl>
1720
+ </FormItem>
1721
+ )}
1722
+ />
1723
+
1724
+ {/* Budget field - Only show when unlimited budget is OFF */}
1725
+ {!watchedUnlimitedBudget && (
1726
+ <FormField
1727
+ control={form.control}
1728
+ name="budget"
1729
+ render={({ field }) => (
1730
+ <FormItem>
1731
+ <FormLabel>Budget <span className="text-destructive">*</span></FormLabel>
1732
+ <div className="flex gap-2">
1733
+ <FormControl>
1734
+ <Input
1735
+ type="number"
1736
+ placeholder="0.00"
1737
+ {...field}
1738
+ data-testid="input-budget"
1739
+ />
1740
+ </FormControl>
1741
+ <div className="flex h-9 items-center rounded-md border bg-muted/50 px-3 text-sm font-medium">
1742
+ <Lock className="h-3 w-3 mr-1.5 text-muted-foreground" />
1743
+ {currentDeal?.currency || "USD"}
1744
+ </div>
1745
+ </div>
1746
+ <FormDescription className="text-xs">
1747
+ Currency inherited from deal
1748
+ </FormDescription>
1749
+ <FormMessage />
1750
+ </FormItem>
1751
+ )}
1752
+ />
1753
+ )}
1754
+
1755
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
1756
+ <FormField
1757
+ control={form.control}
1758
+ name="budgetConsumption"
1759
+ render={({ field }) => (
1760
+ <FormItem>
1761
+ <FormLabel>Budget Consumption</FormLabel>
1762
+ <Select onValueChange={field.onChange} value={field.value || "daily"}>
1763
+ <FormControl>
1764
+ <SelectTrigger data-testid="select-budget-consumption">
1765
+ <SelectValue placeholder="Select consumption" />
1766
+ </SelectTrigger>
1767
+ </FormControl>
1768
+ <SelectContent>
1769
+ <SelectItem value="daily">Daily</SelectItem>
1770
+ <SelectItem value="weekly">Weekly</SelectItem>
1771
+ <SelectItem value="monthly">Monthly</SelectItem>
1772
+ <SelectItem value="lifetime">Lifetime</SelectItem>
1773
+ </SelectContent>
1774
+ </Select>
1775
+ <FormMessage />
1776
+ </FormItem>
1777
+ )}
1778
+ />
1779
+
1780
+ <FormField
1781
+ control={form.control}
1782
+ name="dailyBudget"
1783
+ render={({ field }) => (
1784
+ <FormItem>
1785
+ <FormLabel>Daily Budget ({currentDeal?.currency || "USD"})</FormLabel>
1786
+ <FormControl>
1787
+ <Input
1788
+ type="number"
1789
+ placeholder="0.00"
1790
+ {...field}
1791
+ value={field.value || ""}
1792
+ onChange={(e) => field.onChange(e.target.value)}
1793
+ data-testid="input-daily-budget"
1794
+ />
1795
+ </FormControl>
1796
+ <FormMessage />
1797
+ </FormItem>
1798
+ )}
1799
+ />
1800
+ </div>
1801
+ </CardContent>
1802
+ </Card>
1803
+
1804
+ {/* Pacing & Traffic Allocation Section - Only for programmatic deals */}
1805
+ {!isTraditionalDeal && (
1806
+ <Card>
1807
+ <CardHeader className="pb-3">
1808
+ <div className="flex items-center gap-2">
1809
+ <TrendingUp className="h-4 w-4 text-muted-foreground" />
1810
+ <CardTitle className="text-base">Pacing & Traffic Allocation</CardTitle>
1811
+ </div>
1812
+ </CardHeader>
1813
+ <CardContent className="space-y-4">
1814
+ <FormField
1815
+ control={form.control}
1816
+ name="pacing"
1817
+ render={({ field }) => (
1818
+ <FormItem>
1819
+ <FormLabel>Pacing</FormLabel>
1820
+ <Select onValueChange={field.onChange} value={field.value}>
1821
+ <FormControl>
1822
+ <SelectTrigger data-testid="select-pacing">
1823
+ <SelectValue placeholder="Select pacing" />
1824
+ </SelectTrigger>
1825
+ </FormControl>
1826
+ <SelectContent>
1827
+ {PACING_OPTIONS.map((option) => (
1828
+ <SelectItem key={option.value} value={option.value}>
1829
+ {option.label}
1830
+ </SelectItem>
1831
+ ))}
1832
+ </SelectContent>
1833
+ </Select>
1834
+ <FormDescription>
1835
+ Controls how the budget is spent over the deal duration
1836
+ </FormDescription>
1837
+ <FormMessage />
1838
+ </FormItem>
1839
+ )}
1840
+ />
1841
+
1842
+ <FormField
1843
+ control={form.control}
1844
+ name="trafficAllocation"
1845
+ render={({ field }) => (
1846
+ <FormItem>
1847
+ <FormControl>
1848
+ <TrafficSlider
1849
+ value={field.value}
1850
+ onChange={field.onChange}
1851
+ data-testid="slider-traffic-allocation"
1852
+ />
1853
+ </FormControl>
1854
+ <FormDescription>
1855
+ Controls what percentage of eligible impressions this line item receives
1856
+ </FormDescription>
1857
+ <FormMessage />
1858
+ </FormItem>
1859
+ )}
1860
+ />
1861
+ </CardContent>
1862
+ </Card>
1863
+ )}
1864
+
1865
+ {/* Targeting Section - Summary Row Format */}
1866
+ <Card ref={targetingSectionRef as any}>
1867
+ <CardHeader className="pb-3">
1868
+ <div className="flex items-center gap-2">
1869
+ <Target className="h-4 w-4 text-muted-foreground" />
1870
+ <CardTitle className="text-base">Targeting</CardTitle>
1871
+ </div>
1872
+ </CardHeader>
1873
+ <CardContent className="space-y-2">
1874
+ {/* Media Type Row */}
1875
+ <div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
1876
+ <div className="flex items-center gap-3">
1877
+ <Monitor className="h-4 w-4 text-muted-foreground" />
1878
+ <span className="text-sm">Media Type</span>
1879
+ </div>
1880
+ <div className="flex items-center gap-2">
1881
+ <span className="text-sm font-medium">DOOH</span>
1882
+ <Check className="h-4 w-4 text-primary" />
1883
+ </div>
1884
+ </div>
1885
+
1886
+ {/* Geography Row */}
1887
+ <FormField
1888
+ control={form.control}
1889
+ name="geography"
1890
+ render={({ field }) => (
1891
+ <FormItem className="space-y-0">
1892
+ <div
1893
+ className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
1894
+ onClick={() => setAdvancedMapDrawerOpen(true)}
1895
+ data-testid="targeting-row-geography"
1896
+ >
1897
+ <div className="flex items-center gap-3">
1898
+ <MapPin className="h-4 w-4 text-muted-foreground" />
1899
+ <span className="text-sm">Geography</span>
1900
+ </div>
1901
+ <div className="flex items-center gap-2">
1902
+ <span className="text-sm text-muted-foreground">
1903
+ {field.value?.length || 0} locations selected
1904
+ </span>
1905
+ <Check className={`h-4 w-4 ${(field.value?.length || 0) > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
1906
+ </div>
1907
+ </div>
1908
+ </FormItem>
1909
+ )}
1910
+ />
1911
+
1912
+ {/* Age Targeting Row */}
1913
+ <FormField
1914
+ control={form.control}
1915
+ name="demographics"
1916
+ render={({ field }) => {
1917
+ const selectedAges = field.value.filter((v: string) => AGE_GROUPS.includes(v));
1918
+ const isOpen = activeTargetingSection === "age";
1919
+ return (
1920
+ <FormItem className="space-y-0">
1921
+ <div data-collapsible-section="age">
1922
+ <Collapsible open={isOpen} onOpenChange={(open) => setActiveTargetingSection(open ? "age" : null)}>
1923
+ <CollapsibleTrigger asChild>
1924
+ <div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
1925
+ data-testid="targeting-row-age"
1926
+ >
1927
+ <div className="flex items-center gap-3">
1928
+ <Users className="h-4 w-4 text-muted-foreground" />
1929
+ <span className="text-sm">Age Groups</span>
1930
+ </div>
1931
+ <div className="flex items-center gap-2">
1932
+ <span className="text-sm text-muted-foreground">
1933
+ {selectedAges.length} age groups selected
1934
+ </span>
1935
+ <Check className={`h-4 w-4 ${selectedAges.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
1936
+ </div>
1937
+ </div>
1938
+ </CollapsibleTrigger>
1939
+ <CollapsibleContent className="pt-2 pl-10">
1940
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-2 p-3 bg-muted/30 rounded-md">
1941
+ {AGE_GROUPS.map((age) => (
1942
+ <label key={age} className="flex items-center gap-2 cursor-pointer text-sm">
1943
+ <Checkbox
1944
+ checked={field.value.includes(age)}
1945
+ onCheckedChange={(checked) => {
1946
+ if (checked) {
1947
+ field.onChange([...field.value, age]);
1948
+ } else {
1949
+ field.onChange(field.value.filter((v: string) => v !== age));
1950
+ }
1951
+ }}
1952
+ />
1953
+ {age}
1954
+ </label>
1955
+ ))}
1956
+ </div>
1957
+ </CollapsibleContent>
1958
+ </Collapsible>
1959
+ </div>
1960
+ </FormItem>
1961
+ );
1962
+ }}
1963
+ />
1964
+
1965
+ {/* Gender Targeting Row */}
1966
+ <FormField
1967
+ control={form.control}
1968
+ name="demographics"
1969
+ render={({ field }) => {
1970
+ const selectedGenders = field.value.filter((v: string) => GENDERS.includes(v));
1971
+ const isOpen = activeTargetingSection === "gender";
1972
+ return (
1973
+ <FormItem className="space-y-0">
1974
+ <div data-collapsible-section="gender">
1975
+ <Collapsible open={isOpen} onOpenChange={(open) => setActiveTargetingSection(open ? "gender" : null)}>
1976
+ <CollapsibleTrigger asChild>
1977
+ <div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
1978
+ data-testid="targeting-row-gender"
1979
+ >
1980
+ <div className="flex items-center gap-3">
1981
+ <Users className="h-4 w-4 text-muted-foreground" />
1982
+ <span className="text-sm">Gender</span>
1983
+ </div>
1984
+ <div className="flex items-center gap-2">
1985
+ <span className="text-sm text-muted-foreground">
1986
+ {selectedGenders.length} genders selected
1987
+ </span>
1988
+ <Check className={`h-4 w-4 ${selectedGenders.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
1989
+ </div>
1990
+ </div>
1991
+ </CollapsibleTrigger>
1992
+ <CollapsibleContent className="pt-2 pl-10">
1993
+ <div className="grid grid-cols-2 gap-2 p-3 bg-muted/30 rounded-md">
1994
+ {GENDERS.map((gender) => (
1995
+ <label key={gender} className="flex items-center gap-2 cursor-pointer text-sm">
1996
+ <Checkbox
1997
+ checked={field.value.includes(gender)}
1998
+ onCheckedChange={(checked) => {
1999
+ if (checked) {
2000
+ field.onChange([...field.value, gender]);
2001
+ } else {
2002
+ field.onChange(field.value.filter((v: string) => v !== gender));
2003
+ }
2004
+ }}
2005
+ />
2006
+ {gender}
2007
+ </label>
2008
+ ))}
2009
+ </div>
2010
+ </CollapsibleContent>
2011
+ </Collapsible>
2012
+ </div>
2013
+ </FormItem>
2014
+ );
2015
+ }}
2016
+ />
2017
+
2018
+ {/* POI Targeting Row */}
2019
+ <FormField
2020
+ control={form.control}
2021
+ name="selectedPOIs"
2022
+ render={({ field }) => (
2023
+ <FormItem className="space-y-0">
2024
+ <div
2025
+ className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
2026
+ onClick={() => setPoiDrawerOpen(true)}
2027
+ data-testid="targeting-row-poi"
2028
+ >
2029
+ <div className="flex items-center gap-3">
2030
+ <MapPin className="h-4 w-4 text-muted-foreground" />
2031
+ <span className="text-sm">POI Targeting</span>
2032
+ </div>
2033
+ <div className="flex items-center gap-2">
2034
+ <span className="text-sm text-muted-foreground">
2035
+ {field.value.length} POIs, {selectedPOICategories.length} categories
2036
+ </span>
2037
+ <Check className={`h-4 w-4 ${field.value.length > 0 || selectedPOICategories.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
2038
+ </div>
2039
+ </div>
2040
+ </FormItem>
2041
+ )}
2042
+ />
2043
+
2044
+ {/* Venue Type Row - Opens Drawer */}
2045
+ <FormField
2046
+ control={form.control}
2047
+ name="venueTypes"
2048
+ render={({ field }) => (
2049
+ <FormItem className="space-y-0">
2050
+ <div
2051
+ className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
2052
+ onClick={() => setVenueTypeDrawerOpen(true)}
2053
+ data-testid="targeting-row-venue-type"
2054
+ >
2055
+ <div className="flex items-center gap-3">
2056
+ <Layers className="h-4 w-4 text-muted-foreground" />
2057
+ <span className="text-sm">Venue Type</span>
2058
+ </div>
2059
+ <div className="flex items-center gap-2">
2060
+ <span className="text-sm text-muted-foreground">
2061
+ {field.value.length} venue type{field.value.length !== 1 ? 's' : ''} selected
2062
+ </span>
2063
+ <Check className={`h-4 w-4 ${field.value.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
2064
+ </div>
2065
+ </div>
2066
+ </FormItem>
2067
+ )}
2068
+ />
2069
+
2070
+ {/* Ad Resolution Row */}
2071
+ <FormField
2072
+ control={form.control}
2073
+ name="resolution"
2074
+ render={({ field }) => (
2075
+ <FormItem className="space-y-0">
2076
+ <Collapsible>
2077
+ <CollapsibleTrigger asChild>
2078
+ <div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
2079
+ data-testid="targeting-row-resolution"
2080
+ >
2081
+ <div className="flex items-center gap-3">
2082
+ <Monitor className="h-4 w-4 text-muted-foreground" />
2083
+ <span className="text-sm">Ad Resolution</span>
2084
+ </div>
2085
+ <div className="flex items-center gap-2">
2086
+ <span className="text-sm text-muted-foreground">
2087
+ {field.value ? `${field.value} selected` : "0 Ad Resolutions are selected"}
2088
+ </span>
2089
+ <Check className={`h-4 w-4 ${field.value ? 'text-primary' : 'text-muted-foreground/30'}`} />
2090
+ </div>
2091
+ </div>
2092
+ </CollapsibleTrigger>
2093
+ <CollapsibleContent className="pt-2 pl-10">
2094
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-2 p-3 bg-muted/30 rounded-md">
2095
+ {SCREEN_RESOLUTIONS.map((res) => (
2096
+ <label key={res} className="flex items-center gap-2 cursor-pointer text-sm">
2097
+ <Checkbox
2098
+ checked={field.value === res}
2099
+ onCheckedChange={(checked) => {
2100
+ field.onChange(checked ? res : "");
2101
+ }}
2102
+ />
2103
+ {res}
2104
+ </label>
2105
+ ))}
2106
+ </div>
2107
+ </CollapsibleContent>
2108
+ </Collapsible>
2109
+ </FormItem>
2110
+ )}
2111
+ />
2112
+
2113
+ {/* Ad Duration Row */}
2114
+ <FormField
2115
+ control={form.control}
2116
+ name="creativeDuration"
2117
+ render={({ field }) => (
2118
+ <FormItem className="space-y-0">
2119
+ <Collapsible>
2120
+ <CollapsibleTrigger asChild>
2121
+ <div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
2122
+ data-testid="targeting-row-duration"
2123
+ >
2124
+ <div className="flex items-center gap-3">
2125
+ <Clock className="h-4 w-4 text-muted-foreground" />
2126
+ <span className="text-sm">Ad Duration</span>
2127
+ </div>
2128
+ <div className="flex items-center gap-2">
2129
+ <span className="text-sm text-muted-foreground">
2130
+ {field.value}s selected
2131
+ </span>
2132
+ <Check className={`h-4 w-4 ${field.value ? 'text-primary' : 'text-muted-foreground/30'}`} />
2133
+ </div>
2134
+ </div>
2135
+ </CollapsibleTrigger>
2136
+ <CollapsibleContent className="pt-2 pl-10">
2137
+ <div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-md">
2138
+ {DURATION_OPTIONS.map((duration) => (
2139
+ <Button
2140
+ key={duration}
2141
+ type="button"
2142
+ variant={field.value === duration ? "default" : "outline"}
2143
+ size="sm"
2144
+ onClick={() => field.onChange(duration)}
2145
+ >
2146
+ {duration}s
2147
+ </Button>
2148
+ ))}
2149
+ <div className="flex items-center gap-2 ml-4">
2150
+ <span className="text-xs text-muted-foreground">Custom:</span>
2151
+ <Input
2152
+ type="number"
2153
+ className="w-20 h-8"
2154
+ min={1}
2155
+ value={field.value}
2156
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 10)}
2157
+ />
2158
+ </div>
2159
+ </div>
2160
+ </CollapsibleContent>
2161
+ </Collapsible>
2162
+ </FormItem>
2163
+ )}
2164
+ />
2165
+ </CardContent>
2166
+ </Card>
2167
+
2168
+ {/* DOOH Inventory Type Section */}
2169
+ <Card ref={inventorySectionRef as any}>
2170
+ <CardHeader className="pb-3">
2171
+ <div className="flex items-center gap-2">
2172
+ <Monitor className="h-4 w-4 text-muted-foreground" />
2173
+ <CardTitle className="text-base">DOOH Inventory Type</CardTitle>
2174
+ </div>
2175
+ </CardHeader>
2176
+ <CardContent className="space-y-2">
2177
+ {/* SSP / Exchange Row */}
2178
+ <div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
2179
+ <div className="flex items-center gap-3">
2180
+ <Server className="h-4 w-4 text-muted-foreground" />
2181
+ <span className="text-sm">SSP / Exchange</span>
2182
+ </div>
2183
+ <div className="flex items-center gap-2">
2184
+ <span className="text-sm font-medium">Influence SSP</span>
2185
+ <Check className="h-4 w-4 text-primary" />
2186
+ </div>
2187
+ </div>
2188
+
2189
+ {/* Media Owner Row */}
2190
+ <div
2191
+ className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
2192
+ onClick={() => setMediaOwnerDrawerOpen(true)}
2193
+ data-testid="inventory-row-media-owner"
2194
+ >
2195
+ <div className="flex items-center gap-3">
2196
+ <Building className="h-4 w-4 text-muted-foreground" />
2197
+ <span className="text-sm">Media Owner</span>
2198
+ </div>
2199
+ <div className="flex items-center gap-2">
2200
+ <span className="text-sm text-muted-foreground">
2201
+ {selectedMediaOwners.length} Media Owners selected
2202
+ </span>
2203
+ <Check className={`h-4 w-4 ${selectedMediaOwners.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
2204
+ </div>
2205
+ </div>
2206
+
2207
+ {/* DOOH Inventory Type Row */}
2208
+ <FormField
2209
+ control={form.control}
2210
+ name="inventoryType"
2211
+ render={({ field }) => {
2212
+ const isOpen = activeTargetingSection === "inventoryType";
2213
+ return (
2214
+ <FormItem className="space-y-0">
2215
+ <div data-collapsible-section="inventoryType">
2216
+ <Collapsible open={isOpen} onOpenChange={(open) => setActiveTargetingSection(open ? "inventoryType" : null)}>
2217
+ <CollapsibleTrigger asChild>
2218
+ <div className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
2219
+ data-testid="inventory-row-dooh"
2220
+ >
2221
+ <div className="flex items-center gap-3">
2222
+ <Monitor className="h-4 w-4 text-muted-foreground" />
2223
+ <span className="text-sm">Inventory Type</span>
2224
+ </div>
2225
+ <div className="flex items-center gap-2">
2226
+ <span className="text-sm text-muted-foreground">
2227
+ {field.value.length} types selected
2228
+ </span>
2229
+ <Check className={`h-4 w-4 ${field.value.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
2230
+ </div>
2231
+ </div>
2232
+ </CollapsibleTrigger>
2233
+ <CollapsibleContent className="pt-2 pl-10">
2234
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-3 bg-muted/30 rounded-md">
2235
+ {INVENTORY_TYPES.map((option) => (
2236
+ <label key={option} className="flex items-center gap-2 cursor-pointer text-sm">
2237
+ <Checkbox
2238
+ checked={field.value.includes(option)}
2239
+ onCheckedChange={(checked) => {
2240
+ if (checked) {
2241
+ field.onChange([...field.value, option]);
2242
+ } else {
2243
+ field.onChange(field.value.filter((v: string) => v !== option));
2244
+ }
2245
+ }}
2246
+ />
2247
+ {option}
2248
+ </label>
2249
+ ))}
2250
+ </div>
2251
+ </CollapsibleContent>
2252
+ </Collapsible>
2253
+ </div>
2254
+ </FormItem>
2255
+ );
2256
+ }}
2257
+ />
2258
+
2259
+ {/* Inventory Format Row */}
2260
+ <FormField
2261
+ control={form.control}
2262
+ name="inventoryFormat"
2263
+ render={({ field }) => (
2264
+ <FormItem className="space-y-0">
2265
+ <div
2266
+ className="flex items-center justify-between p-3 rounded-md border hover-elevate cursor-pointer"
2267
+ onClick={() => setInventoryFormatDrawerOpen(true)}
2268
+ data-testid="inventory-row-format"
2269
+ >
2270
+ <div className="flex items-center gap-3">
2271
+ <Layout className="h-4 w-4 text-muted-foreground" />
2272
+ <span className="text-sm">Inventory Format</span>
2273
+ </div>
2274
+ <div className="flex items-center gap-2">
2275
+ <span className="text-sm text-muted-foreground">
2276
+ {field.value.length} formats selected
2277
+ </span>
2278
+ <Check className={`h-4 w-4 ${field.value.length > 0 ? 'text-primary' : 'text-muted-foreground/30'}`} />
2279
+ </div>
2280
+ </div>
2281
+ </FormItem>
2282
+ )}
2283
+ />
2284
+ </CardContent>
2285
+ </Card>
2286
+
2287
+ {/* AI Inventory Recommendations Section */}
2288
+ <AIRecommendationPanel
2289
+ dealId={dealId || ""}
2290
+ startDate={watchedStartDate}
2291
+ endDate={watchedEndDate}
2292
+ budget={watchedBudget ? parseFloat(watchedBudget) : undefined}
2293
+ country={currentDeal?.countries?.[0]}
2294
+ selectedScreens={watchedSelectedScreens || []}
2295
+ onAddScreens={(screenIds) => {
2296
+ const current = form.getValues("selectedScreens") || [];
2297
+ const newScreens = Array.from(new Set([...current, ...screenIds]));
2298
+ form.setValue("selectedScreens", newScreens);
2299
+ }}
2300
+ onOpenManualEdit={() => setManualInventoryDrawerOpen(true)}
2301
+ />
2302
+
2303
+ <Sheet open={inventoryDrawerOpen} onOpenChange={setInventoryDrawerOpen}>
2304
+ <SheetContent className="w-full sm:max-w-2xl overflow-hidden flex flex-col">
2305
+ <SheetHeader>
2306
+ <SheetTitle>Edit Inventory Selection</SheetTitle>
2307
+ <SheetDescription>
2308
+ Select or deselect screens to include in your line item. Click markers on the map or use checkboxes.
2309
+ </SheetDescription>
2310
+ </SheetHeader>
2311
+ <div className="flex-1 overflow-hidden grid grid-cols-1 gap-4 mt-4">
2312
+ <div className="h-64 rounded-md overflow-hidden border">
2313
+ <InventoryMapComponent
2314
+ center={mapCenter}
2315
+ zoom={mapCenter.zoom}
2316
+ screens={fetchedScreens}
2317
+ selectedIds={selectedScreenIds}
2318
+ onScreenClick={handleToggleScreen}
2319
+ />
2320
+ </div>
2321
+ <div className="flex-1 overflow-hidden">
2322
+ <div className="flex items-center justify-between mb-2">
2323
+ <span className="text-sm font-medium">
2324
+ {(watchedSelectedScreens || []).length} of {fetchedScreens.length} screens selected
2325
+ </span>
2326
+ <div className="flex gap-2">
2327
+ <Button
2328
+ type="button"
2329
+ variant="outline"
2330
+ size="sm"
2331
+ onClick={() => form.setValue("selectedScreens", fetchedScreens.map(s => s.id))}
2332
+ data-testid="button-select-all-screens"
2333
+ >
2334
+ Select All
2335
+ </Button>
2336
+ <Button
2337
+ type="button"
2338
+ variant="outline"
2339
+ size="sm"
2340
+ onClick={() => form.setValue("selectedScreens", [])}
2341
+ data-testid="button-deselect-all-screens"
2342
+ >
2343
+ Deselect All
2344
+ </Button>
2345
+ </div>
2346
+ </div>
2347
+ <ScrollArea className="h-[300px] border rounded-md">
2348
+ <div className="p-3 space-y-2">
2349
+ {fetchedScreens.map((screen) => (
2350
+ <label
2351
+ key={screen.id}
2352
+ className="flex items-center gap-3 p-2 rounded-md hover-elevate cursor-pointer"
2353
+ >
2354
+ <Checkbox
2355
+ checked={selectedScreenIds.has(screen.id)}
2356
+ onCheckedChange={() => handleToggleScreen(screen.id)}
2357
+ data-testid={`checkbox-screen-${screen.id}`}
2358
+ />
2359
+ <div className="flex-1 min-w-0">
2360
+ <div className="font-medium text-sm truncate">{screen.name}</div>
2361
+ <div className="text-xs text-muted-foreground">
2362
+ {screen.city || ''}{screen.city && screen.country ? ', ' : ''}{screen.country || ''} • CPM: ${screen.cpm || '0.00'}
2363
+ </div>
2364
+ </div>
2365
+ <Monitor className="h-4 w-4 text-muted-foreground flex-shrink-0" />
2366
+ </label>
2367
+ ))}
2368
+ {fetchedScreens.length === 0 && (
2369
+ <p className="text-sm text-muted-foreground text-center py-4">
2370
+ No screens available. Fetch inventory first.
2371
+ </p>
2372
+ )}
2373
+ </div>
2374
+ </ScrollArea>
2375
+ </div>
2376
+ </div>
2377
+ <div className="flex justify-end gap-2 mt-4 pt-4 border-t">
2378
+ <Button
2379
+ type="button"
2380
+ variant="outline"
2381
+ onClick={() => setInventoryDrawerOpen(false)}
2382
+ data-testid="button-cancel-inventory"
2383
+ >
2384
+ Cancel
2385
+ </Button>
2386
+ <Button
2387
+ type="button"
2388
+ onClick={() => setInventoryDrawerOpen(false)}
2389
+ data-testid="button-apply-inventory"
2390
+ >
2391
+ Apply Selection
2392
+ </Button>
2393
+ </div>
2394
+ </SheetContent>
2395
+ </Sheet>
2396
+
2397
+ {/* Inventory Availability Section - Shows when screens are selected */}
2398
+ {(watchedSelectedScreens?.length ?? 0) > 0 && (
2399
+ <InventoryAvailabilitySection
2400
+ selectedScreenIds={watchedSelectedScreens || []}
2401
+ startDate={watchedStartDate}
2402
+ endDate={watchedEndDate}
2403
+ onViewDetails={() => setAvailabilityDrawerOpen(true)}
2404
+ />
2405
+ )}
2406
+
2407
+ {/* Day Parting Section */}
2408
+ <Card>
2409
+ <CardHeader className="pb-3">
2410
+ <div className="flex items-center gap-2">
2411
+ <Clock className="h-4 w-4 text-muted-foreground" />
2412
+ <CardTitle className="text-base">Day Parting</CardTitle>
2413
+ </div>
2414
+ <p className="text-sm text-muted-foreground">
2415
+ Set specific days and hours when ads should run
2416
+ </p>
2417
+ </CardHeader>
2418
+ <CardContent>
2419
+ <FormField
2420
+ control={form.control}
2421
+ name="schedules"
2422
+ render={({ field }) => (
2423
+ <ScheduleEditor
2424
+ value={field.value || []}
2425
+ onChange={field.onChange}
2426
+ />
2427
+ )}
2428
+ />
2429
+ </CardContent>
2430
+ </Card>
2431
+
2432
+ {/* Billing Section */}
2433
+ <Card>
2434
+ <CardHeader className="pb-3">
2435
+ <div className="flex items-center gap-2">
2436
+ <Receipt className="h-4 w-4 text-muted-foreground" />
2437
+ <CardTitle className="text-base">Billing</CardTitle>
2438
+ </div>
2439
+ </CardHeader>
2440
+ <CardContent className="space-y-4">
2441
+ <FormField
2442
+ control={form.control}
2443
+ name="billable"
2444
+ render={({ field }) => (
2445
+ <FormItem className="space-y-3">
2446
+ <FormControl>
2447
+ <RadioGroup
2448
+ onValueChange={(value) => field.onChange(value === "billable")}
2449
+ value={field.value ? "billable" : "non_billable"}
2450
+ className="flex flex-col gap-3"
2451
+ >
2452
+ <div className="flex items-center space-x-3 p-3 rounded-md border hover-elevate cursor-pointer">
2453
+ <RadioGroupItem value="non_billable" id="non_billable" data-testid="radio-non-billable" />
2454
+ <label htmlFor="non_billable" className="flex-1 cursor-pointer">
2455
+ <div className="text-sm font-medium">Non Billable</div>
2456
+ <div className="text-xs text-muted-foreground">This line item will not be billed</div>
2457
+ </label>
2458
+ </div>
2459
+ <div className="flex items-center space-x-3 p-3 rounded-md border hover-elevate cursor-pointer">
2460
+ <RadioGroupItem value="billable" id="billable" data-testid="radio-billable" />
2461
+ <label htmlFor="billable" className="flex-1 cursor-pointer">
2462
+ <div className="text-sm font-medium">Billable</div>
2463
+ <div className="text-xs text-muted-foreground">This line item will be included in billing</div>
2464
+ </label>
2465
+ </div>
2466
+ </RadioGroup>
2467
+ </FormControl>
2468
+ </FormItem>
2469
+ )}
2470
+ />
2471
+ </CardContent>
2472
+ </Card>
2473
+
2474
+ {/* Bid Strategy Section */}
2475
+ <Card>
2476
+ <CardHeader className="pb-3">
2477
+ <div className="flex items-center gap-2">
2478
+ <Gauge className="h-4 w-4 text-muted-foreground" />
2479
+ <CardTitle className="text-base">Bid Strategy</CardTitle>
2480
+ </div>
2481
+ </CardHeader>
2482
+ <CardContent className="space-y-4">
2483
+ <FormField
2484
+ control={form.control}
2485
+ name="automatedBidding"
2486
+ render={({ field }) => (
2487
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
2488
+ <div className="space-y-0.5">
2489
+ <FormLabel>Automated Bidding</FormLabel>
2490
+ <FormDescription>
2491
+ Let the system automatically optimize bid amounts
2492
+ </FormDescription>
2493
+ </div>
2494
+ <FormControl>
2495
+ <Switch
2496
+ checked={field.value || false}
2497
+ onCheckedChange={field.onChange}
2498
+ data-testid="switch-automated-bidding"
2499
+ />
2500
+ </FormControl>
2501
+ </FormItem>
2502
+ )}
2503
+ />
2504
+
2505
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
2506
+ <FormField
2507
+ control={form.control}
2508
+ name="maxBid"
2509
+ render={({ field }) => (
2510
+ <FormItem>
2511
+ <FormLabel>Max Bid ($)</FormLabel>
2512
+ <div className="space-y-2">
2513
+ <FormControl>
2514
+ <Input
2515
+ type="number"
2516
+ step="0.01"
2517
+ placeholder="0.00"
2518
+ {...field}
2519
+ data-testid="input-max-bid"
2520
+ />
2521
+ </FormControl>
2522
+ {suggestedMaxBid && !maxBidDiffersFromSuggestion && (
2523
+ <div className="flex items-center gap-2 p-2 rounded-md bg-muted/50 border">
2524
+ <span className="text-sm text-muted-foreground">
2525
+ Suggested: <span className="font-medium text-foreground">${suggestedMaxBid}</span>
2526
+ <span className="text-xs ml-1">
2527
+ ({(watchedSelectedScreens?.length || 0) > 0 ? "avg of selected screens" : "based on inventory filters"})
2528
+ </span>
2529
+ </span>
2530
+ <Button
2531
+ type="button"
2532
+ variant="outline"
2533
+ size="sm"
2534
+ onClick={applyMaxBidSuggestion}
2535
+ data-testid="button-apply-max-bid"
2536
+ >
2537
+ Apply
2538
+ </Button>
2539
+ </div>
2540
+ )}
2541
+ {maxBidDiffersFromSuggestion && (
2542
+ <div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
2543
+ <span className="text-sm text-muted-foreground flex-1">
2544
+ Suggested: <span className="font-medium text-foreground">${suggestedMaxBid}</span>
2545
+ <span className="text-xs ml-1 text-amber-600 dark:text-amber-400">
2546
+ (differs from current)
2547
+ </span>
2548
+ </span>
2549
+ <Button
2550
+ type="button"
2551
+ variant="outline"
2552
+ size="sm"
2553
+ onClick={applyMaxBidSuggestion}
2554
+ data-testid="button-use-suggested-bid"
2555
+ >
2556
+ Use Suggested
2557
+ </Button>
2558
+ </div>
2559
+ )}
2560
+ </div>
2561
+ <FormDescription>
2562
+ Maximum bid amount. {!suggestedMaxBid && "Select inventory or screens to see a suggested rate."}
2563
+ </FormDescription>
2564
+ <FormMessage />
2565
+ </FormItem>
2566
+ )}
2567
+ />
2568
+
2569
+ <FormField
2570
+ control={form.control}
2571
+ name="bidType"
2572
+ render={({ field }) => (
2573
+ <FormItem>
2574
+ <FormLabel>Bid Type</FormLabel>
2575
+ <Select onValueChange={field.onChange} value={field.value || "cpm"}>
2576
+ <FormControl>
2577
+ <SelectTrigger data-testid="select-bid-type">
2578
+ <SelectValue placeholder="Select bid type" />
2579
+ </SelectTrigger>
2580
+ </FormControl>
2581
+ <SelectContent>
2582
+ <SelectItem value="cpm">CPM</SelectItem>
2583
+ <SelectItem value="cps">CPS</SelectItem>
2584
+ </SelectContent>
2585
+ </Select>
2586
+ <FormMessage />
2587
+ </FormItem>
2588
+ )}
2589
+ />
2590
+ </div>
2591
+
2592
+ {auctionType && (
2593
+ <FormItem>
2594
+ <FormLabel className="flex items-center gap-2">
2595
+ Auction Type
2596
+ <Badge variant="outline" className="text-xs font-normal">
2597
+ <Lock className="h-3 w-3 mr-1" />
2598
+ Auto
2599
+ </Badge>
2600
+ </FormLabel>
2601
+ <Input
2602
+ value={auctionType}
2603
+ disabled
2604
+ className="bg-muted"
2605
+ data-testid="input-auction-type"
2606
+ />
2607
+ <FormDescription>
2608
+ Determined by the deal type
2609
+ </FormDescription>
2610
+ </FormItem>
2611
+ )}
2612
+
2613
+ {isPGDeal && currentDeal && (
2614
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
2615
+ <div className="space-y-0.5">
2616
+ <div className="flex items-center gap-2">
2617
+ <FormLabel>Hard Stop</FormLabel>
2618
+ <Badge variant="outline" className="text-xs">From Deal</Badge>
2619
+ </div>
2620
+ <FormDescription>
2621
+ {currentDeal.hardStop
2622
+ ? "Enabled - Delivery stops when budget is exhausted"
2623
+ : "Disabled - Delivery continues past budget"}
2624
+ </FormDescription>
2625
+ </div>
2626
+ <Checkbox
2627
+ checked={currentDeal.hardStop ?? false}
2628
+ disabled
2629
+ data-testid="checkbox-hard-stop"
2630
+ />
2631
+ </FormItem>
2632
+ )}
2633
+ </CardContent>
2634
+ </Card>
2635
+
2636
+ {/* Custom Fees Section - Table Format */}
2637
+ <Card>
2638
+ <CardHeader className="pb-3">
2639
+ <div className="flex items-center justify-between">
2640
+ <div className="flex items-center gap-2">
2641
+ <Receipt className="h-4 w-4 text-muted-foreground" />
2642
+ <CardTitle className="text-base">Custom Fees</CardTitle>
2643
+ </div>
2644
+ <Button
2645
+ type="button"
2646
+ variant="outline"
2647
+ size="sm"
2648
+ onClick={() => appendFee({ name: "", amount: 0, type: "fixed", hidden: false })}
2649
+ data-testid="button-add-fee"
2650
+ >
2651
+ <Plus className="h-4 w-4 mr-1" />
2652
+ Add Fee
2653
+ </Button>
2654
+ </div>
2655
+ </CardHeader>
2656
+ <CardContent>
2657
+ {feeFields.length === 0 ? (
2658
+ <p className="text-sm text-muted-foreground text-center py-4">No custom fees added</p>
2659
+ ) : (
2660
+ <div className="border rounded-md overflow-hidden">
2661
+ <table className="w-full text-sm">
2662
+ <thead className="bg-muted/50">
2663
+ <tr>
2664
+ <th className="text-left p-3 font-medium">Name</th>
2665
+ <th className="text-left p-3 font-medium">Amount</th>
2666
+ <th className="text-left p-3 font-medium">Type</th>
2667
+ <th className="text-center p-3 font-medium">Invoiced</th>
2668
+ <th className="text-center p-3 font-medium w-12">Action</th>
2669
+ </tr>
2670
+ </thead>
2671
+ <tbody className="divide-y">
2672
+ {feeFields.map((feeField, index) => (
2673
+ <tr key={feeField.id} className="hover-elevate">
2674
+ <td className="p-2">
2675
+ <FormField
2676
+ control={form.control}
2677
+ name={`customFees.${index}.name`}
2678
+ render={({ field }) => (
2679
+ <Input
2680
+ placeholder="Fee name"
2681
+ className="h-8"
2682
+ {...field}
2683
+ data-testid={`input-fee-name-${index}`}
2684
+ />
2685
+ )}
2686
+ />
2687
+ </td>
2688
+ <td className="p-2">
2689
+ <FormField
2690
+ control={form.control}
2691
+ name={`customFees.${index}.amount`}
2692
+ render={({ field }) => (
2693
+ <Input
2694
+ type="number"
2695
+ step="0.01"
2696
+ placeholder="0.00"
2697
+ className="h-8 w-24"
2698
+ {...field}
2699
+ onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
2700
+ data-testid={`input-fee-amount-${index}`}
2701
+ />
2702
+ )}
2703
+ />
2704
+ </td>
2705
+ <td className="p-2">
2706
+ <FormField
2707
+ control={form.control}
2708
+ name={`customFees.${index}.type`}
2709
+ render={({ field }) => (
2710
+ <Select onValueChange={field.onChange} value={field.value}>
2711
+ <SelectTrigger className="h-8 w-28" data-testid={`select-fee-type-${index}`}>
2712
+ <SelectValue />
2713
+ </SelectTrigger>
2714
+ <SelectContent>
2715
+ <SelectItem value="fixed">Fixed</SelectItem>
2716
+ <SelectItem value="percentage">Percentage</SelectItem>
2717
+ </SelectContent>
2718
+ </Select>
2719
+ )}
2720
+ />
2721
+ </td>
2722
+ <td className="p-2 text-center">
2723
+ <FormField
2724
+ control={form.control}
2725
+ name={`customFees.${index}.hidden`}
2726
+ render={({ field }) => (
2727
+ <Checkbox
2728
+ checked={!field.value}
2729
+ onCheckedChange={(checked) => field.onChange(!checked)}
2730
+ data-testid={`checkbox-fee-invoiced-${index}`}
2731
+ />
2732
+ )}
2733
+ />
2734
+ </td>
2735
+ <td className="p-2 text-center">
2736
+ <Button
2737
+ type="button"
2738
+ variant="ghost"
2739
+ size="icon"
2740
+ className="h-8 w-8"
2741
+ onClick={() => removeFee(index)}
2742
+ data-testid={`button-remove-fee-${index}`}
2743
+ >
2744
+ <Trash2 className="h-4 w-4 text-destructive" />
2745
+ </Button>
2746
+ </td>
2747
+ </tr>
2748
+ ))}
2749
+ </tbody>
2750
+ </table>
2751
+ </div>
2752
+ )}
2753
+ </CardContent>
2754
+ </Card>
2755
+
2756
+ {/* Advanced Options */}
2757
+ <Card>
2758
+ <CardHeader>
2759
+ <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
2760
+ <CollapsibleTrigger asChild>
2761
+ <Button variant="ghost" className="w-full justify-between p-0 h-auto hover:bg-transparent">
2762
+ <div className="flex items-center gap-2">
2763
+ <Settings className="h-4 w-4 text-muted-foreground" />
2764
+ <CardTitle className="text-base">Advanced Options</CardTitle>
2765
+ </div>
2766
+ {advancedOpen ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
2767
+ </Button>
2768
+ </CollapsibleTrigger>
2769
+ </Collapsible>
2770
+ </CardHeader>
2771
+ <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
2772
+ <CollapsibleContent>
2773
+ <CardContent className="space-y-6">
2774
+ {/* Frequency Cap - only for programmatic deals (not PG or Traditional) */}
2775
+ {!isPGDeal && !isTraditionalDeal && (
2776
+ <div className="space-y-4">
2777
+ <FormLabel className="text-base font-medium">Frequency Cap</FormLabel>
2778
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
2779
+ <FormField
2780
+ control={form.control}
2781
+ name="frequencyCapImpressions"
2782
+ render={({ field }) => (
2783
+ <FormItem>
2784
+ <FormLabel>Ad Plays</FormLabel>
2785
+ <FormControl>
2786
+ <Input
2787
+ type="number"
2788
+ placeholder="e.g., 5"
2789
+ {...field}
2790
+ data-testid="input-frequency-impressions"
2791
+ />
2792
+ </FormControl>
2793
+ <FormDescription>
2794
+ Maximum ad plays per inventory per period
2795
+ </FormDescription>
2796
+ <FormMessage />
2797
+ </FormItem>
2798
+ )}
2799
+ />
2800
+
2801
+ <FormField
2802
+ control={form.control}
2803
+ name="frequencyCapPeriod"
2804
+ render={({ field }) => (
2805
+ <FormItem>
2806
+ <FormLabel>Period</FormLabel>
2807
+ <Select onValueChange={field.onChange} value={field.value}>
2808
+ <FormControl>
2809
+ <SelectTrigger data-testid="select-frequency-period">
2810
+ <SelectValue placeholder="Select period" />
2811
+ </SelectTrigger>
2812
+ </FormControl>
2813
+ <SelectContent>
2814
+ <SelectItem value="hour">Hour</SelectItem>
2815
+ <SelectItem value="day">Day</SelectItem>
2816
+ <SelectItem value="week">Week</SelectItem>
2817
+ <SelectItem value="month">Month</SelectItem>
2818
+ <SelectItem value="lifetime">Lifetime</SelectItem>
2819
+ </SelectContent>
2820
+ </Select>
2821
+ <FormMessage />
2822
+ </FormItem>
2823
+ )}
2824
+ />
2825
+ </div>
2826
+ </div>
2827
+ )}
2828
+ </CardContent>
2829
+ </CollapsibleContent>
2830
+ </Collapsible>
2831
+ </Card>
2832
+
2833
+ {/* Signals Section */}
2834
+ <Card>
2835
+ <CardHeader className="pb-3">
2836
+ <div className="flex items-center gap-2">
2837
+ <Zap className="h-4 w-4 text-muted-foreground" />
2838
+ <CardTitle className="text-base">Signals</CardTitle>
2839
+ </div>
2840
+ </CardHeader>
2841
+ <CardContent className="space-y-4">
2842
+ <FormField
2843
+ control={form.control}
2844
+ name="triggerEnabled"
2845
+ render={({ field }) => (
2846
+ <FormItem className="flex items-center justify-between rounded-lg border p-3">
2847
+ <div className="space-y-0.5">
2848
+ <FormLabel>Auto-activate Signal</FormLabel>
2849
+ <FormDescription>
2850
+ Automatically activate line item when signal triggers
2851
+ </FormDescription>
2852
+ </div>
2853
+ <FormControl>
2854
+ <Switch
2855
+ checked={field.value}
2856
+ onCheckedChange={field.onChange}
2857
+ data-testid="toggle-signal-auto-activate"
2858
+ />
2859
+ </FormControl>
2860
+ </FormItem>
2861
+ )}
2862
+ />
2863
+
2864
+ <FormField
2865
+ control={form.control}
2866
+ name="triggerId"
2867
+ render={({ field }) => (
2868
+ <FormItem>
2869
+ <FormLabel>Select Signal</FormLabel>
2870
+ <FormControl>
2871
+ <SearchableCombobox
2872
+ options={signals.map((signal) => ({
2873
+ value: signal.id,
2874
+ label: signal.name,
2875
+ description: `${signal.signalType} • ${signal.status}`,
2876
+ }))}
2877
+ value={field.value}
2878
+ onValueChange={field.onChange}
2879
+ placeholder="Select a signal..."
2880
+ searchPlaceholder="Search signals..."
2881
+ emptyMessage="No signals found."
2882
+ disabled={!form.watch("triggerEnabled")}
2883
+ data-testid="select-signal"
2884
+ />
2885
+ </FormControl>
2886
+ <FormDescription>
2887
+ Choose a signal to control when this line item activates
2888
+ </FormDescription>
2889
+ <FormMessage />
2890
+ </FormItem>
2891
+ )}
2892
+ />
2893
+ </CardContent>
2894
+ </Card>
2895
+ </form>
2896
+ </Form>
2897
+
2898
+ </div>
2899
+
2900
+ <div className="lg:col-span-1 space-y-4">
2901
+ <div className="sticky top-6 space-y-4">
2902
+ {!forecast && (
2903
+ <FormInsightsPanel
2904
+ insights={LINE_ITEM_INSIGHTS}
2905
+ tips={LINE_ITEM_TIPS}
2906
+ currentSection={currentFormSection}
2907
+ sectionInsights={SECTION_INSIGHTS}
2908
+ />
2909
+ )}
2910
+
2911
+ <Card data-testid="panel-forecast">
2912
+ <CardHeader className="pb-3">
2913
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
2914
+ <TrendingUp className="h-4 w-4 text-green-500" />
2915
+ Forecast
2916
+ </CardTitle>
2917
+ </CardHeader>
2918
+ <CardContent className="pt-0">
2919
+ {forecast ? (
2920
+ <div className="space-y-3">
2921
+ <div className="space-y-1">
2922
+ <div className="text-xs text-muted-foreground">Estimated Impressions</div>
2923
+ <div className="text-lg font-semibold" data-testid="text-estimated-impressions">
2924
+ {formatNumber(forecast.estimatedImpressions)}
2925
+ </div>
2926
+ <div className="text-xs text-muted-foreground">
2927
+ Over {forecast.daysInRange} {forecast.daysInRange === 1 ? 'day' : 'days'}
2928
+ </div>
2929
+ </div>
2930
+
2931
+ {forecast.hasMaxBid && (
2932
+ <div className="space-y-2 pt-2 border-t">
2933
+ <div className="text-xs text-muted-foreground font-medium">Cost Breakdown</div>
2934
+ <div className="p-3 rounded-md bg-muted/50 space-y-2" data-testid="panel-cost-breakdown">
2935
+ <div className="flex items-center justify-between text-xs">
2936
+ <span>Media Cost</span>
2937
+ <span className="font-medium">{formatCurrency(forecast.mediaCost)}</span>
2938
+ </div>
2939
+ {forecast.hasCustomFees && (
2940
+ <div className="flex items-center justify-between text-xs">
2941
+ <span>+ Custom Fees</span>
2942
+ <span className="font-medium">{formatCurrency(forecast.customFeesTotal)}</span>
2943
+ </div>
2944
+ )}
2945
+ <div className="flex items-center justify-between text-xs">
2946
+ <span>+ Platform Fee ({forecast.platformFeePercent}%)</span>
2947
+ <span className="font-medium">{formatCurrency(forecast.platformFee)}</span>
2948
+ </div>
2949
+ <div className="flex items-center justify-between text-sm border-t pt-2 mt-2">
2950
+ <span className="font-semibold">Total</span>
2951
+ <span className={`font-bold ${
2952
+ (forecast.exceedsDealBudget || forecast.exceedsLineItemBudget)
2953
+ ? 'text-destructive'
2954
+ : 'text-green-600'
2955
+ }`} data-testid="text-total-cost">
2956
+ {formatCurrency(forecast.totalCost)}
2957
+ </span>
2958
+ </div>
2959
+ </div>
2960
+ </div>
2961
+ )}
2962
+
2963
+ {!forecast.hasMaxBid && (
2964
+ <div className="text-xs text-muted-foreground pt-2 border-t">
2965
+ Set a floor rate to see cost breakdown
2966
+ </div>
2967
+ )}
2968
+
2969
+ {forecast.hasMaxBid && (forecast.dealBudget > 0 || forecast.lineItemBudget > 0) && (
2970
+ <div className="space-y-2 pt-2 border-t">
2971
+ <div className="text-xs text-muted-foreground">Budget Comparison</div>
2972
+ <div className="p-2 rounded-md bg-muted/30 space-y-1" data-testid="panel-budget-comparison">
2973
+ {forecast.dealBudget > 0 && (
2974
+ <div className="flex items-center justify-between text-xs">
2975
+ <span>Deal Budget:</span>
2976
+ <span className="font-medium">{formatCurrency(forecast.dealBudget)}</span>
2977
+ </div>
2978
+ )}
2979
+ {forecast.lineItemBudget > 0 && (
2980
+ <div className="flex items-center justify-between text-xs">
2981
+ <span>Line Item Budget:</span>
2982
+ <span className="font-medium">{formatCurrency(forecast.lineItemBudget)}</span>
2983
+ </div>
2984
+ )}
2985
+ </div>
2986
+ {(forecast.exceedsDealBudget || forecast.exceedsLineItemBudget) && (
2987
+ <div className="flex items-center gap-1 text-xs text-destructive" data-testid="alert-budget-exceeded">
2988
+ <span className="w-2 h-2 rounded-full bg-destructive animate-pulse" />
2989
+ Cost exceeds {forecast.exceedsDealBudget ? "deal" : "line item"} budget
2990
+ </div>
2991
+ )}
2992
+ </div>
2993
+ )}
2994
+
2995
+ {forecast.hasMaxBid && (
2996
+ <div className="space-y-1 pt-2 border-t">
2997
+ <div className="text-xs text-muted-foreground">Cost Efficiency</div>
2998
+ <div className="flex flex-wrap gap-2" data-testid="text-cost-efficiency">
2999
+ <Badge variant="secondary" className="text-xs">
3000
+ eCPM: {formatCurrency(forecast.effectiveCPM)}
3001
+ </Badge>
3002
+ <Badge variant="secondary" className="text-xs">
3003
+ eCPS: {formatCurrency(forecast.effectiveCPS)}
3004
+ </Badge>
3005
+ </div>
3006
+ </div>
3007
+ )}
3008
+
3009
+ {forecast.budgetUtilization !== null && (
3010
+ <div className="space-y-1 pt-2 border-t">
3011
+ <div className="text-xs text-muted-foreground">Budget Utilization</div>
3012
+ <div className="flex items-center gap-2">
3013
+ <div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
3014
+ <div
3015
+ className={`h-full rounded-full ${
3016
+ forecast.budgetUtilization > 100
3017
+ ? 'bg-destructive'
3018
+ : forecast.budgetUtilization > 80
3019
+ ? 'bg-amber-500'
3020
+ : 'bg-green-500'
3021
+ }`}
3022
+ style={{ width: `${Math.min(100, forecast.budgetUtilization)}%` }}
3023
+ />
3024
+ </div>
3025
+ <span className="text-xs font-medium" data-testid="text-budget-utilization">
3026
+ {forecast.budgetUtilization.toFixed(0)}%
3027
+ </span>
3028
+ </div>
3029
+ {forecast.budgetUtilization > 100 && (
3030
+ <div className="text-xs text-destructive">
3031
+ Estimated spend exceeds budget
3032
+ </div>
3033
+ )}
3034
+ </div>
3035
+ )}
3036
+ </div>
3037
+ ) : (
3038
+ <p className="text-sm text-muted-foreground" data-testid="text-forecast-empty">
3039
+ Configure inventory and dates to see forecast
3040
+ </p>
3041
+ )}
3042
+ </CardContent>
3043
+ </Card>
3044
+ </div>
3045
+ </div>
3046
+ </div>
3047
+ </div>
3048
+ </div>
3049
+ </div>
3050
+
3051
+ <div className="absolute bottom-0 left-0 right-0 border-t bg-background p-4 z-10">
3052
+ <div className="flex justify-end gap-4">
3053
+ <Button
3054
+ type="button"
3055
+ variant="outline"
3056
+ onClick={handleCancel}
3057
+ disabled={isSubmitting}
3058
+ data-testid="button-cancel"
3059
+ >
3060
+ Cancel
3061
+ </Button>
3062
+ <Button
3063
+ type="submit"
3064
+ form="line-item-form"
3065
+ disabled={isSubmitting}
3066
+ data-testid="button-submit"
3067
+ >
3068
+ {isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
3069
+ {isEditing ? "Update Line Item" : "Create Line Item"}
3070
+ </Button>
3071
+ </div>
3072
+ </div>
3073
+
3074
+ <MediaOwnerDrawer
3075
+ open={mediaOwnerDrawerOpen}
3076
+ onOpenChange={setMediaOwnerDrawerOpen}
3077
+ selectedMediaOwners={selectedMediaOwners}
3078
+ onSelectionChange={setSelectedMediaOwners}
3079
+ />
3080
+
3081
+ <InventoryFormatDrawer
3082
+ open={inventoryFormatDrawerOpen}
3083
+ onOpenChange={setInventoryFormatDrawerOpen}
3084
+ selectedFormats={form.watch("inventoryFormat")}
3085
+ onSelectionChange={(formats) => form.setValue("inventoryFormat", formats)}
3086
+ selectedTypes={form.watch("inventoryType")}
3087
+ />
3088
+
3089
+ <POITargetingDrawer
3090
+ open={poiDrawerOpen}
3091
+ onOpenChange={setPoiDrawerOpen}
3092
+ selectedPOIs={form.watch("selectedPOIs")}
3093
+ onPOISelectionChange={(pois) => form.setValue("selectedPOIs", pois)}
3094
+ selectedCategories={selectedPOICategories}
3095
+ onCategorySelectionChange={setSelectedPOICategories}
3096
+ mapCenter={{ lng: 103.8198, lat: 1.3521, zoom: 11 }}
3097
+ />
3098
+
3099
+ <AdvancedMapDrawer
3100
+ open={advancedMapDrawerOpen}
3101
+ onOpenChange={setAdvancedMapDrawerOpen}
3102
+ selectedLocations={form.watch("geography")}
3103
+ onSelectionChange={(locations) => form.setValue("geography", locations)}
3104
+ />
3105
+
3106
+ <ManualInventoryDrawer
3107
+ open={manualInventoryDrawerOpen}
3108
+ onOpenChange={setManualInventoryDrawerOpen}
3109
+ screens={fetchedScreens.length > 0 ? fetchedScreens : allScreens}
3110
+ selectedScreens={form.watch("selectedScreens")}
3111
+ onSelectionChange={(screens) => form.setValue("selectedScreens", screens)}
3112
+ mapCenter={{ lng: 103.8198, lat: 1.3521, zoom: 11 }}
3113
+ />
3114
+
3115
+ <VenueTypeDrawer
3116
+ open={venueTypeDrawerOpen}
3117
+ onOpenChange={setVenueTypeDrawerOpen}
3118
+ selectedVenues={form.watch("venueTypes")}
3119
+ onApply={(venues) => form.setValue("venueTypes", venues)}
3120
+ />
3121
+
3122
+ <AvailabilityDrawer
3123
+ open={availabilityDrawerOpen}
3124
+ onOpenChange={setAvailabilityDrawerOpen}
3125
+ screens={allScreens}
3126
+ selectedScreenIds={form.watch("selectedScreens") || []}
3127
+ lineItemStartDate={watchedStartDate}
3128
+ lineItemEndDate={watchedEndDate}
3129
+ />
3130
+ </div>
3131
+ );
3132
+ }