@open-mercato/core 0.4.7-develop-78d7541539 → 0.4.7-develop-c89cca0193

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 (187) hide show
  1. package/AGENTS.md +1 -0
  2. package/dist/modules/catalog/api/bulk-delete/route.js +86 -0
  3. package/dist/modules/catalog/api/bulk-delete/route.js.map +7 -0
  4. package/dist/modules/catalog/api/prices/route.js +39 -6
  5. package/dist/modules/catalog/api/prices/route.js.map +2 -2
  6. package/dist/modules/catalog/api/products/route.js +6 -11
  7. package/dist/modules/catalog/api/products/route.js.map +2 -2
  8. package/dist/modules/catalog/commands/products.js +2 -0
  9. package/dist/modules/catalog/commands/products.js.map +2 -2
  10. package/dist/modules/catalog/components/products/ProductsDataTable.js +9 -1
  11. package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
  12. package/dist/modules/catalog/lib/bulkDelete.js +70 -0
  13. package/dist/modules/catalog/lib/bulkDelete.js.map +7 -0
  14. package/dist/modules/catalog/widgets/injection/product-bulk-delete/widget.js +185 -0
  15. package/dist/modules/catalog/widgets/injection/product-bulk-delete/widget.js.map +7 -0
  16. package/dist/modules/catalog/widgets/injection-table.js +9 -1
  17. package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
  18. package/dist/modules/catalog/workers/catalog-product-bulk-delete.js +40 -0
  19. package/dist/modules/catalog/workers/catalog-product-bulk-delete.js.map +7 -0
  20. package/dist/modules/data_sync/api/options.js +52 -0
  21. package/dist/modules/data_sync/api/options.js.map +7 -0
  22. package/dist/modules/data_sync/api/run.js +30 -35
  23. package/dist/modules/data_sync/api/run.js.map +2 -2
  24. package/dist/modules/data_sync/api/runs/[id]/cancel.js +2 -2
  25. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  26. package/dist/modules/data_sync/api/runs/[id]/retry.js +15 -30
  27. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
  28. package/dist/modules/data_sync/api/schedules/[id]/route.js +109 -0
  29. package/dist/modules/data_sync/api/schedules/[id]/route.js.map +7 -0
  30. package/dist/modules/data_sync/api/schedules/route.js +72 -0
  31. package/dist/modules/data_sync/api/schedules/route.js.map +7 -0
  32. package/dist/modules/data_sync/api/schedules/serialize.js +21 -0
  33. package/dist/modules/data_sync/api/schedules/serialize.js.map +7 -0
  34. package/dist/modules/data_sync/backend/data-sync/page.js +656 -47
  35. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  36. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +116 -34
  37. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  38. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +394 -0
  39. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +7 -0
  40. package/dist/modules/data_sync/data/validators.js +32 -0
  41. package/dist/modules/data_sync/data/validators.js.map +2 -2
  42. package/dist/modules/data_sync/di.js +2 -0
  43. package/dist/modules/data_sync/di.js.map +2 -2
  44. package/dist/modules/data_sync/lib/id-mapping.js +24 -2
  45. package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
  46. package/dist/modules/data_sync/lib/start-run.js +57 -0
  47. package/dist/modules/data_sync/lib/start-run.js.map +7 -0
  48. package/dist/modules/data_sync/lib/sync-engine.js +93 -4
  49. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  50. package/dist/modules/data_sync/lib/sync-run-service.js +5 -1
  51. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  52. package/dist/modules/data_sync/lib/sync-schedule-service.js +138 -0
  53. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +7 -0
  54. package/dist/modules/data_sync/workers/sync-export.js +28 -2
  55. package/dist/modules/data_sync/workers/sync-export.js.map +2 -2
  56. package/dist/modules/data_sync/workers/sync-import.js +28 -2
  57. package/dist/modules/data_sync/workers/sync-import.js.map +2 -2
  58. package/dist/modules/data_sync/workers/sync-scheduled.js +5 -0
  59. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  60. package/dist/modules/entities/api/definitions.js +5 -2
  61. package/dist/modules/entities/api/definitions.js.map +2 -2
  62. package/dist/modules/entities/lib/field-definitions.js +3 -1
  63. package/dist/modules/entities/lib/field-definitions.js.map +2 -2
  64. package/dist/modules/integrations/api/[id]/route.js +14 -15
  65. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  66. package/dist/modules/integrations/api/route.js +3 -3
  67. package/dist/modules/integrations/api/route.js.map +2 -2
  68. package/dist/modules/integrations/backend/integrations/[id]/page.js +148 -33
  69. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  70. package/dist/modules/integrations/lib/state-service.js +15 -1
  71. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  72. package/dist/modules/messages/api/[id]/route.js +24 -22
  73. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  74. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
  75. package/dist/modules/progress/api/active/route.js +3 -1
  76. package/dist/modules/progress/api/active/route.js.map +2 -2
  77. package/dist/modules/progress/api/jobs/[id]/route.js +1 -1
  78. package/dist/modules/progress/api/jobs/[id]/route.js.map +2 -2
  79. package/dist/modules/progress/api/jobs/route.js +1 -1
  80. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  81. package/dist/modules/progress/lib/events.js.map +1 -1
  82. package/dist/modules/progress/lib/progressService.js.map +2 -2
  83. package/dist/modules/progress/lib/progressServiceImpl.js +42 -1
  84. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  85. package/dist/modules/query_index/lib/document.js +35 -1
  86. package/dist/modules/query_index/lib/document.js.map +2 -2
  87. package/dist/modules/query_index/lib/engine.js +91 -4
  88. package/dist/modules/query_index/lib/engine.js.map +2 -2
  89. package/dist/modules/query_index/lib/indexer.js +2 -0
  90. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  91. package/dist/modules/sales/api/adjustment-kinds/route.js +3 -9
  92. package/dist/modules/sales/api/adjustment-kinds/route.js.map +2 -2
  93. package/dist/modules/sales/api/channels/route.js +3 -10
  94. package/dist/modules/sales/api/channels/route.js.map +2 -2
  95. package/dist/modules/sales/api/delivery-windows/route.js +3 -10
  96. package/dist/modules/sales/api/delivery-windows/route.js.map +2 -2
  97. package/dist/modules/sales/api/payment-methods/route.js +3 -11
  98. package/dist/modules/sales/api/payment-methods/route.js.map +2 -2
  99. package/dist/modules/sales/api/price-kinds/route.js +3 -5
  100. package/dist/modules/sales/api/price-kinds/route.js.map +2 -2
  101. package/dist/modules/sales/api/shipping-methods/route.js +3 -11
  102. package/dist/modules/sales/api/shipping-methods/route.js.map +2 -2
  103. package/dist/modules/sales/api/tags/route.js +3 -9
  104. package/dist/modules/sales/api/tags/route.js.map +2 -2
  105. package/dist/modules/sales/api/tax-rates/route.js +3 -13
  106. package/dist/modules/sales/api/tax-rates/route.js.map +2 -2
  107. package/dist/modules/sales/api/utils.js +9 -0
  108. package/dist/modules/sales/api/utils.js.map +2 -2
  109. package/dist/modules/sales/lib/makeStatusDictionaryRoute.js +3 -9
  110. package/dist/modules/sales/lib/makeStatusDictionaryRoute.js.map +2 -2
  111. package/dist/modules/workflows/api/definitions/[id]/route.js +3 -2
  112. package/dist/modules/workflows/api/definitions/[id]/route.js.map +2 -2
  113. package/dist/modules/workflows/api/definitions/route.js +4 -3
  114. package/dist/modules/workflows/api/definitions/route.js.map +2 -2
  115. package/dist/modules/workflows/api/definitions/serialize.js +25 -0
  116. package/dist/modules/workflows/api/definitions/serialize.js.map +7 -0
  117. package/package.json +3 -3
  118. package/src/modules/catalog/api/bulk-delete/route.ts +93 -0
  119. package/src/modules/catalog/api/prices/route.ts +53 -6
  120. package/src/modules/catalog/api/products/route.ts +6 -11
  121. package/src/modules/catalog/commands/products.ts +2 -0
  122. package/src/modules/catalog/components/products/ProductsDataTable.tsx +8 -0
  123. package/src/modules/catalog/i18n/de.json +10 -0
  124. package/src/modules/catalog/i18n/en.json +10 -0
  125. package/src/modules/catalog/i18n/es.json +10 -0
  126. package/src/modules/catalog/i18n/pl.json +10 -0
  127. package/src/modules/catalog/lib/bulkDelete.ts +106 -0
  128. package/src/modules/catalog/widgets/injection/product-bulk-delete/widget.ts +242 -0
  129. package/src/modules/catalog/widgets/injection-table.ts +8 -0
  130. package/src/modules/catalog/workers/catalog-product-bulk-delete.ts +48 -0
  131. package/src/modules/data_sync/AGENTS.md +11 -3
  132. package/src/modules/data_sync/api/options.ts +58 -0
  133. package/src/modules/data_sync/api/run.ts +34 -36
  134. package/src/modules/data_sync/api/runs/[id]/cancel.ts +2 -2
  135. package/src/modules/data_sync/api/runs/[id]/retry.ts +14 -31
  136. package/src/modules/data_sync/api/schedules/[id]/route.ts +130 -0
  137. package/src/modules/data_sync/api/schedules/route.ts +77 -0
  138. package/src/modules/data_sync/api/schedules/serialize.ts +31 -0
  139. package/src/modules/data_sync/backend/data-sync/page.tsx +756 -2
  140. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +179 -53
  141. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +512 -0
  142. package/src/modules/data_sync/data/validators.ts +35 -0
  143. package/src/modules/data_sync/di.ts +6 -0
  144. package/src/modules/data_sync/i18n/de.json +72 -0
  145. package/src/modules/data_sync/i18n/en.json +72 -0
  146. package/src/modules/data_sync/i18n/es.json +72 -0
  147. package/src/modules/data_sync/i18n/pl.json +72 -0
  148. package/src/modules/data_sync/lib/adapter.ts +4 -1
  149. package/src/modules/data_sync/lib/id-mapping.ts +32 -2
  150. package/src/modules/data_sync/lib/start-run.ts +90 -0
  151. package/src/modules/data_sync/lib/sync-engine.ts +111 -4
  152. package/src/modules/data_sync/lib/sync-run-service.ts +5 -1
  153. package/src/modules/data_sync/lib/sync-schedule-service.ts +207 -0
  154. package/src/modules/data_sync/workers/sync-export.ts +33 -2
  155. package/src/modules/data_sync/workers/sync-import.ts +33 -2
  156. package/src/modules/data_sync/workers/sync-scheduled.ts +7 -0
  157. package/src/modules/entities/api/definitions.ts +12 -2
  158. package/src/modules/entities/lib/field-definitions.ts +2 -0
  159. package/src/modules/integrations/AGENTS.md +16 -3
  160. package/src/modules/integrations/api/[id]/route.ts +14 -15
  161. package/src/modules/integrations/api/route.ts +3 -3
  162. package/src/modules/integrations/backend/integrations/[id]/page.tsx +176 -54
  163. package/src/modules/integrations/lib/state-service.ts +25 -1
  164. package/src/modules/messages/api/[id]/route.ts +25 -22
  165. package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +3 -3
  166. package/src/modules/progress/api/active/route.ts +4 -1
  167. package/src/modules/progress/api/jobs/[id]/route.ts +1 -1
  168. package/src/modules/progress/api/jobs/route.ts +1 -1
  169. package/src/modules/progress/lib/events.ts +6 -0
  170. package/src/modules/progress/lib/progressService.ts +1 -0
  171. package/src/modules/progress/lib/progressServiceImpl.ts +47 -1
  172. package/src/modules/query_index/lib/document.ts +52 -1
  173. package/src/modules/query_index/lib/engine.ts +104 -4
  174. package/src/modules/query_index/lib/indexer.ts +2 -0
  175. package/src/modules/sales/api/adjustment-kinds/route.ts +3 -9
  176. package/src/modules/sales/api/channels/route.ts +3 -10
  177. package/src/modules/sales/api/delivery-windows/route.ts +3 -10
  178. package/src/modules/sales/api/payment-methods/route.ts +3 -11
  179. package/src/modules/sales/api/price-kinds/route.ts +3 -5
  180. package/src/modules/sales/api/shipping-methods/route.ts +3 -11
  181. package/src/modules/sales/api/tags/route.ts +3 -9
  182. package/src/modules/sales/api/tax-rates/route.ts +3 -13
  183. package/src/modules/sales/api/utils.ts +9 -0
  184. package/src/modules/sales/lib/makeStatusDictionaryRoute.ts +3 -9
  185. package/src/modules/workflows/api/definitions/[id]/route.ts +3 -2
  186. package/src/modules/workflows/api/definitions/route.ts +4 -3
  187. package/src/modules/workflows/api/definitions/serialize.ts +23 -0
@@ -1,16 +1,38 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
+ import Link from 'next/link'
3
4
  import { useRouter } from 'next/navigation'
4
5
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
5
6
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
6
7
  import type { ColumnDef } from '@tanstack/react-table'
7
8
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
8
- import { Badge } from '@open-mercato/ui/primitives/badge'
9
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
+ import { Badge, type BadgeProps } from '@open-mercato/ui/primitives/badge'
11
+ import { Card, CardContent, CardHeader, CardTitle } from '@open-mercato/ui/primitives/card'
12
+ import { Button } from '@open-mercato/ui/primitives/button'
13
+ import { Input } from '@open-mercato/ui/primitives/input'
14
+ import { Label } from '@open-mercato/ui/primitives/label'
15
+ import { Notice } from '@open-mercato/ui/primitives/Notice'
16
+ import { Separator } from '@open-mercato/ui/primitives/separator'
17
+ import { Switch } from '@open-mercato/ui/primitives/switch'
9
18
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
10
19
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
11
20
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
12
21
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
13
22
  import { useT } from '@open-mercato/shared/lib/i18n/context'
23
+ import {
24
+ ArrowRightLeft,
25
+ Boxes,
26
+ CalendarClock,
27
+ CircleAlert,
28
+ Clock3,
29
+ Gauge,
30
+ Play,
31
+ PlugZap,
32
+ Repeat,
33
+ Settings2,
34
+ ShieldCheck,
35
+ } from 'lucide-react'
14
36
 
15
37
  type SyncRunRow = {
16
38
  id: string
@@ -31,6 +53,49 @@ type ResponsePayload = {
31
53
  totalPages: number
32
54
  }
33
55
 
56
+ type SyncOption = {
57
+ integrationId: string
58
+ title: string
59
+ description?: string | null
60
+ providerKey?: string | null
61
+ direction: 'import' | 'export' | 'bidirectional'
62
+ supportedEntities: string[]
63
+ hasCredentials: boolean
64
+ isEnabled: boolean
65
+ settingsPath: string
66
+ }
67
+
68
+ type SyncOptionsResponse = {
69
+ items: SyncOption[]
70
+ }
71
+
72
+ type SyncScheduleRecord = {
73
+ id: string
74
+ integrationId: string
75
+ entityType: string
76
+ direction: 'import' | 'export'
77
+ scheduleType: 'cron' | 'interval'
78
+ scheduleValue: string
79
+ timezone: string
80
+ fullSync: boolean
81
+ isEnabled: boolean
82
+ lastRunAt: string | null
83
+ }
84
+
85
+ type SyncSchedulesResponse = {
86
+ items?: SyncScheduleRecord[]
87
+ }
88
+
89
+ type SyncScheduleEditorState = {
90
+ id?: string
91
+ scheduleType: 'cron' | 'interval'
92
+ scheduleValue: string
93
+ timezone: string
94
+ fullSync: boolean
95
+ isEnabled: boolean
96
+ lastRunAt: string | null
97
+ }
98
+
34
99
  const STATUS_STYLES: Record<string, string> = {
35
100
  pending: 'bg-gray-100 text-gray-800',
36
101
  running: 'bg-blue-100 text-blue-800',
@@ -40,18 +105,93 @@ const STATUS_STYLES: Record<string, string> = {
40
105
  paused: 'bg-orange-100 text-orange-800',
41
106
  }
42
107
 
108
+ const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
109
+
110
+ type SummaryBadgeStyle = {
111
+ variant: BadgeProps['variant']
112
+ className?: string
113
+ }
114
+
115
+ function getSummaryBadgeStyle(kind: 'enabled' | 'disabled' | 'ready' | 'missing' | 'scheduled' | 'paused' | 'none'): SummaryBadgeStyle {
116
+ if (kind === 'enabled' || kind === 'ready') {
117
+ return {
118
+ variant: 'outline',
119
+ className: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-200',
120
+ }
121
+ }
122
+
123
+ if (kind === 'disabled' || kind === 'missing') {
124
+ return {
125
+ variant: 'outline',
126
+ className: 'border-red-500/30 bg-red-500/15 text-red-200',
127
+ }
128
+ }
129
+
130
+ if (kind === 'paused') {
131
+ return {
132
+ variant: 'outline',
133
+ className: 'border-amber-500/30 bg-amber-500/15 text-amber-200',
134
+ }
135
+ }
136
+
137
+ if (kind === 'scheduled') {
138
+ return {
139
+ variant: 'outline',
140
+ className: 'border-sky-500/30 bg-sky-500/15 text-sky-200',
141
+ }
142
+ }
143
+
144
+ return {
145
+ variant: 'outline',
146
+ className: 'border-muted-foreground/20 bg-muted/40 text-muted-foreground',
147
+ }
148
+ }
149
+
150
+ function formatEntityTypeLabel(entityType: string): string {
151
+ return entityType
152
+ .replace(/[_-]+/g, ' ')
153
+ .replace(/\b\w/g, (letter) => letter.toUpperCase())
154
+ }
155
+
156
+ function buildDefaultScheduleState(entityType: string): SyncScheduleEditorState {
157
+ const normalized = entityType.trim().toLowerCase()
158
+ const longerInterval = normalized === 'categories' || normalized === 'attributes'
159
+ return {
160
+ scheduleType: 'interval',
161
+ scheduleValue: longerInterval ? '6h' : '1h',
162
+ timezone: DEFAULT_TIMEZONE,
163
+ fullSync: normalized !== 'products',
164
+ isEnabled: true,
165
+ lastRunAt: null,
166
+ }
167
+ }
168
+
43
169
  export default function SyncRunsDashboardPage() {
44
170
  const router = useRouter()
45
171
  const [rows, setRows] = React.useState<SyncRunRow[]>([])
172
+ const [options, setOptions] = React.useState<SyncOption[]>([])
46
173
  const [page, setPage] = React.useState(1)
47
174
  const [total, setTotal] = React.useState(0)
48
175
  const [totalPages, setTotalPages] = React.useState(1)
49
176
  const [search, setSearch] = React.useState('')
50
177
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
51
178
  const [isLoading, setIsLoading] = React.useState(true)
179
+ const [isLoadingOptions, setIsLoadingOptions] = React.useState(true)
180
+ const [selectedIntegrationId, setSelectedIntegrationId] = React.useState('')
181
+ const [selectedEntityType, setSelectedEntityType] = React.useState('')
182
+ const [selectedDirection, setSelectedDirection] = React.useState<'import' | 'export'>('import')
183
+ const [batchSize, setBatchSize] = React.useState('100')
184
+ const [fullSync, setFullSync] = React.useState(false)
185
+ const [scheduleEditor, setScheduleEditor] = React.useState<SyncScheduleEditorState>(() => buildDefaultScheduleState(''))
186
+ const [isLoadingSchedule, setIsLoadingSchedule] = React.useState(false)
187
+ const [isSavingSchedule, setIsSavingSchedule] = React.useState(false)
188
+ const [isDeletingSchedule, setIsDeletingSchedule] = React.useState(false)
52
189
  const [reloadToken, setReloadToken] = React.useState(0)
53
190
  const scopeVersion = useOrganizationScopeVersion()
54
191
  const t = useT()
192
+ const { runMutation } = useGuardedMutation<Record<string, unknown>>({
193
+ contextId: 'data_sync.dashboard',
194
+ })
55
195
 
56
196
  React.useEffect(() => {
57
197
  let cancelled = false
@@ -85,6 +225,113 @@ export default function SyncRunsDashboardPage() {
85
225
  return () => { cancelled = true }
86
226
  }, [page, filterValues, reloadToken, scopeVersion, t])
87
227
 
228
+ React.useEffect(() => {
229
+ let cancelled = false
230
+ async function loadOptions() {
231
+ setIsLoadingOptions(true)
232
+ const fallback: SyncOptionsResponse = { items: [] }
233
+ const call = await apiCall<SyncOptionsResponse>('/api/data_sync/options', undefined, { fallback })
234
+ if (!cancelled) {
235
+ if (!call.ok) {
236
+ flash(t('data_sync.dashboard.loadError'), 'error')
237
+ setOptions([])
238
+ setIsLoadingOptions(false)
239
+ return
240
+ }
241
+
242
+ const nextItems = Array.isArray(call.result?.items) ? call.result.items : []
243
+ setOptions(nextItems)
244
+ setSelectedIntegrationId((current) => {
245
+ if (current && nextItems.some((item) => item.integrationId === current)) return current
246
+ return nextItems[0]?.integrationId ?? ''
247
+ })
248
+ setIsLoadingOptions(false)
249
+ }
250
+ }
251
+
252
+ void loadOptions()
253
+ return () => { cancelled = true }
254
+ }, [scopeVersion, t])
255
+
256
+ const selectedIntegration = React.useMemo(
257
+ () => options.find((item) => item.integrationId === selectedIntegrationId) ?? null,
258
+ [options, selectedIntegrationId],
259
+ )
260
+
261
+ const entityOptions = React.useMemo(
262
+ () => selectedIntegration?.supportedEntities ?? [],
263
+ [selectedIntegration],
264
+ )
265
+
266
+ React.useEffect(() => {
267
+ if (!selectedIntegration) {
268
+ setSelectedEntityType('')
269
+ return
270
+ }
271
+ setSelectedEntityType((current) => (
272
+ current && selectedIntegration.supportedEntities.includes(current)
273
+ ? current
274
+ : (selectedIntegration.supportedEntities[0] ?? '')
275
+ ))
276
+ setSelectedDirection(selectedIntegration.direction === 'export' ? 'export' : 'import')
277
+ }, [selectedIntegration])
278
+
279
+ React.useEffect(() => {
280
+ if (!selectedIntegration || !selectedEntityType) {
281
+ setScheduleEditor(buildDefaultScheduleState(selectedEntityType))
282
+ return
283
+ }
284
+
285
+ const currentIntegration = selectedIntegration
286
+ let cancelled = false
287
+ async function loadSchedule() {
288
+ setIsLoadingSchedule(true)
289
+ const integrationId = currentIntegration.integrationId
290
+ const params = new URLSearchParams({
291
+ integrationId,
292
+ entityType: selectedEntityType,
293
+ direction: selectedDirection,
294
+ page: '1',
295
+ pageSize: '1',
296
+ })
297
+ const fallback: SyncSchedulesResponse = { items: [] }
298
+ const call = await apiCall<SyncSchedulesResponse>(`/api/data_sync/schedules?${params.toString()}`, undefined, { fallback })
299
+
300
+ if (cancelled) return
301
+
302
+ if (!call.ok) {
303
+ setScheduleEditor(buildDefaultScheduleState(selectedEntityType))
304
+ setIsLoadingSchedule(false)
305
+ return
306
+ }
307
+
308
+ const record = Array.isArray(call.result?.items) ? call.result?.items[0] : undefined
309
+ if (!record) {
310
+ setScheduleEditor(buildDefaultScheduleState(selectedEntityType))
311
+ setIsLoadingSchedule(false)
312
+ return
313
+ }
314
+
315
+ setScheduleEditor({
316
+ id: record.id,
317
+ scheduleType: record.scheduleType,
318
+ scheduleValue: record.scheduleValue,
319
+ timezone: record.timezone,
320
+ fullSync: record.fullSync,
321
+ isEnabled: record.isEnabled,
322
+ lastRunAt: record.lastRunAt,
323
+ })
324
+ setIsLoadingSchedule(false)
325
+ }
326
+
327
+ void loadSchedule()
328
+ return () => { cancelled = true }
329
+ }, [selectedDirection, selectedEntityType, selectedIntegration, scopeVersion])
330
+
331
+ const updateScheduleEditor = React.useCallback((changes: Partial<SyncScheduleEditorState>) => {
332
+ setScheduleEditor((current) => ({ ...current, ...changes }))
333
+ }, [])
334
+
88
335
  const handleCancel = React.useCallback(async (row: SyncRunRow) => {
89
336
  const call = await apiCall(`/api/data_sync/runs/${encodeURIComponent(row.id)}/cancel`, {
90
337
  method: 'POST',
@@ -125,6 +372,153 @@ export default function SyncRunsDashboardPage() {
125
372
  setPage(1)
126
373
  }, [])
127
374
 
375
+ const handleStartSync = React.useCallback(async () => {
376
+ if (!selectedIntegration || !selectedEntityType) return
377
+
378
+ const parsedBatchSize = Number.parseInt(batchSize, 10)
379
+ if (!Number.isFinite(parsedBatchSize) || parsedBatchSize < 1 || parsedBatchSize > 1000) {
380
+ flash(t('data_sync.dashboard.start.invalidBatchSize', 'Batch size must be between 1 and 1000.'), 'error')
381
+ return
382
+ }
383
+
384
+ try {
385
+ const call = await runMutation({
386
+ operation: () => apiCall<{ id: string }>('/api/data_sync/run', {
387
+ method: 'POST',
388
+ headers: { 'Content-Type': 'application/json' },
389
+ body: JSON.stringify({
390
+ integrationId: selectedIntegration.integrationId,
391
+ entityType: selectedEntityType,
392
+ direction: selectedDirection,
393
+ batchSize: parsedBatchSize,
394
+ fullSync,
395
+ }),
396
+ }, { fallback: null }),
397
+ mutationPayload: {
398
+ integrationId: selectedIntegration.integrationId,
399
+ entityType: selectedEntityType,
400
+ direction: selectedDirection,
401
+ batchSize: parsedBatchSize,
402
+ fullSync,
403
+ },
404
+ context: {
405
+ operation: 'create',
406
+ actionId: 'start-sync-run',
407
+ integrationId: selectedIntegration.integrationId,
408
+ },
409
+ })
410
+
411
+ if (!call.ok || !call.result?.id) {
412
+ flash((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.start.error', 'Failed to start sync run'), 'error')
413
+ return
414
+ }
415
+
416
+ flash(t('data_sync.dashboard.start.success', 'Sync run started'), 'success')
417
+ setReloadToken((token) => token + 1)
418
+ router.push(`/backend/data-sync/runs/${encodeURIComponent(call.result.id)}`)
419
+ } catch (error) {
420
+ const message = error instanceof Error ? error.message : t('data_sync.dashboard.start.error', 'Failed to start sync run')
421
+ flash(message, 'error')
422
+ }
423
+ }, [batchSize, fullSync, router, runMutation, selectedDirection, selectedEntityType, selectedIntegration, t])
424
+
425
+ const handleSaveSchedule = React.useCallback(async () => {
426
+ if (!selectedIntegration || !selectedEntityType) return
427
+ if (scheduleEditor.scheduleValue.trim().length === 0) {
428
+ flash(t('data_sync.dashboard.schedule.invalidValue', 'Provide a schedule value before saving.'), 'error')
429
+ return
430
+ }
431
+
432
+ setIsSavingSchedule(true)
433
+ try {
434
+ const call = await runMutation({
435
+ operation: () => apiCall<SyncScheduleRecord>('/api/data_sync/schedules', {
436
+ method: 'POST',
437
+ headers: { 'Content-Type': 'application/json' },
438
+ body: JSON.stringify({
439
+ integrationId: selectedIntegration.integrationId,
440
+ entityType: selectedEntityType,
441
+ direction: selectedDirection,
442
+ scheduleType: scheduleEditor.scheduleType,
443
+ scheduleValue: scheduleEditor.scheduleValue.trim(),
444
+ timezone: scheduleEditor.timezone.trim() || DEFAULT_TIMEZONE,
445
+ fullSync: scheduleEditor.fullSync,
446
+ isEnabled: scheduleEditor.isEnabled,
447
+ }),
448
+ }, { fallback: null }),
449
+ mutationPayload: {
450
+ integrationId: selectedIntegration.integrationId,
451
+ entityType: selectedEntityType,
452
+ direction: selectedDirection,
453
+ scheduleType: scheduleEditor.scheduleType,
454
+ scheduleValue: scheduleEditor.scheduleValue.trim(),
455
+ timezone: scheduleEditor.timezone.trim() || DEFAULT_TIMEZONE,
456
+ fullSync: scheduleEditor.fullSync,
457
+ isEnabled: scheduleEditor.isEnabled,
458
+ },
459
+ context: {
460
+ operation: 'update',
461
+ actionId: 'save-sync-schedule',
462
+ integrationId: selectedIntegration.integrationId,
463
+ },
464
+ })
465
+
466
+ if (!call.ok || !call.result) {
467
+ flash((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.schedule.error', 'Failed to save recurring schedule'), 'error')
468
+ return
469
+ }
470
+
471
+ setScheduleEditor({
472
+ id: call.result.id,
473
+ scheduleType: call.result.scheduleType,
474
+ scheduleValue: call.result.scheduleValue,
475
+ timezone: call.result.timezone,
476
+ fullSync: call.result.fullSync,
477
+ isEnabled: call.result.isEnabled,
478
+ lastRunAt: call.result.lastRunAt,
479
+ })
480
+ flash(t('data_sync.dashboard.schedule.success', 'Recurring schedule saved'), 'success')
481
+ } catch (error) {
482
+ const message = error instanceof Error ? error.message : t('data_sync.dashboard.schedule.error', 'Failed to save recurring schedule')
483
+ flash(message, 'error')
484
+ } finally {
485
+ setIsSavingSchedule(false)
486
+ }
487
+ }, [runMutation, scheduleEditor, selectedDirection, selectedEntityType, selectedIntegration, t])
488
+
489
+ const handleDeleteSchedule = React.useCallback(async () => {
490
+ if (!scheduleEditor.id) return
491
+
492
+ setIsDeletingSchedule(true)
493
+ try {
494
+ const call = await runMutation({
495
+ operation: () => apiCall(`/api/data_sync/schedules/${encodeURIComponent(scheduleEditor.id as string)}`, {
496
+ method: 'DELETE',
497
+ }, { fallback: null }),
498
+ mutationPayload: {
499
+ scheduleId: scheduleEditor.id,
500
+ },
501
+ context: {
502
+ operation: 'delete',
503
+ actionId: 'delete-sync-schedule',
504
+ },
505
+ })
506
+
507
+ if (!call.ok) {
508
+ flash((call.result as { error?: string } | null)?.error ?? t('data_sync.dashboard.schedule.deleteError', 'Failed to remove recurring schedule'), 'error')
509
+ return
510
+ }
511
+
512
+ setScheduleEditor(buildDefaultScheduleState(selectedEntityType))
513
+ flash(t('data_sync.dashboard.schedule.deleteSuccess', 'Recurring schedule removed'), 'success')
514
+ } catch (error) {
515
+ const message = error instanceof Error ? error.message : t('data_sync.dashboard.schedule.deleteError', 'Failed to remove recurring schedule')
516
+ flash(message, 'error')
517
+ } finally {
518
+ setIsDeletingSchedule(false)
519
+ }
520
+ }, [runMutation, scheduleEditor.id, selectedEntityType, t])
521
+
128
522
  const filters: FilterDef[] = [
129
523
  {
130
524
  id: 'status',
@@ -198,9 +592,369 @@ export default function SyncRunsDashboardPage() {
198
592
  },
199
593
  ], [t])
200
594
 
595
+ const canStartSelectedIntegration = Boolean(
596
+ selectedIntegration
597
+ && selectedEntityType
598
+ && selectedIntegration.isEnabled
599
+ && selectedIntegration.hasCredentials,
600
+ )
601
+ const hasSavedSchedule = Boolean(scheduleEditor.id)
602
+ const selectedEntityLabel = selectedEntityType ? formatEntityTypeLabel(selectedEntityType) : t('data_sync.dashboard.columns.entityType')
603
+ const integrationStateBadge = getSummaryBadgeStyle(selectedIntegration?.isEnabled ? 'enabled' : 'disabled')
604
+ const credentialsBadge = getSummaryBadgeStyle(selectedIntegration?.hasCredentials ? 'ready' : 'missing')
605
+ const scheduleBadge = getSummaryBadgeStyle(
606
+ hasSavedSchedule
607
+ ? (scheduleEditor.isEnabled ? 'scheduled' : 'paused')
608
+ : 'none',
609
+ )
610
+
201
611
  return (
202
612
  <Page>
203
- <PageBody>
613
+ <PageBody className="space-y-6">
614
+ <Card>
615
+ <CardHeader className="space-y-4">
616
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
617
+ <div className="space-y-2">
618
+ <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
619
+ <Repeat className="size-4" />
620
+ <span>{t('data_sync.dashboard.start.eyebrow', 'Run once or keep it recurring')}</span>
621
+ </div>
622
+ <div className="space-y-1">
623
+ <CardTitle>{t('data_sync.dashboard.start.title', 'Start or schedule a sync')}</CardTitle>
624
+ <p className="max-w-3xl text-sm text-muted-foreground">
625
+ {t('data_sync.dashboard.start.description', 'Pick a sync target, launch an ad-hoc run, or save a recurring schedule for the same entity and direction from this page.')}
626
+ </p>
627
+ </div>
628
+ </div>
629
+ {selectedIntegration ? (
630
+ <Button asChild variant="outline">
631
+ <Link href={selectedIntegration.settingsPath}>
632
+ <Settings2 className="mr-2 size-4" />
633
+ {t('integrations.marketplace.configure')}
634
+ </Link>
635
+ </Button>
636
+ ) : null}
637
+ </div>
638
+
639
+ {selectedIntegration ? (
640
+ <div className="flex flex-wrap gap-2">
641
+ <Badge variant="outline" className="gap-1.5">
642
+ <PlugZap className="size-3.5" />
643
+ {selectedIntegration.title}
644
+ </Badge>
645
+ <Badge variant="outline" className="gap-1.5">
646
+ <ArrowRightLeft className="size-3.5" />
647
+ {t(`data_sync.dashboard.direction.${selectedDirection}`)}
648
+ </Badge>
649
+ <Badge variant={integrationStateBadge.variant} className={`gap-1.5 ${integrationStateBadge.className ?? ''}`}>
650
+ <ShieldCheck className="size-3.5" />
651
+ {selectedIntegration.isEnabled
652
+ ? t('data_sync.dashboard.start.status.enabled', 'Integration enabled')
653
+ : t('data_sync.dashboard.start.status.disabled', 'Integration disabled')}
654
+ </Badge>
655
+ <Badge variant={credentialsBadge.variant} className={`gap-1.5 ${credentialsBadge.className ?? ''}`}>
656
+ <PlugZap className="size-3.5" />
657
+ {selectedIntegration.hasCredentials
658
+ ? t('data_sync.dashboard.start.status.credentialsReady', 'Credentials ready')
659
+ : t('data_sync.dashboard.start.status.credentialsMissing', 'Credentials missing')}
660
+ </Badge>
661
+ <Badge variant={scheduleBadge.variant} className={`gap-1.5 ${scheduleBadge.className ?? ''}`}>
662
+ <CalendarClock className="size-3.5" />
663
+ {hasSavedSchedule
664
+ ? (scheduleEditor.isEnabled
665
+ ? t('data_sync.dashboard.schedule.status.enabled', 'Recurring schedule active')
666
+ : t('data_sync.dashboard.schedule.status.disabled', 'Recurring schedule paused'))
667
+ : t('data_sync.dashboard.schedule.status.none', 'No recurring schedule')}
668
+ </Badge>
669
+ </div>
670
+ ) : null}
671
+ </CardHeader>
672
+ <CardContent className="space-y-6">
673
+ <div className="grid gap-4 xl:grid-cols-3">
674
+ <div className="space-y-2 xl:col-span-1">
675
+ <Label className="flex items-center gap-2 text-sm font-medium">
676
+ <PlugZap className="size-4 text-muted-foreground" />
677
+ <span>{t('data_sync.dashboard.columns.integration')}</span>
678
+ </Label>
679
+ <select
680
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
681
+ value={selectedIntegrationId}
682
+ onChange={(event) => setSelectedIntegrationId(event.target.value)}
683
+ disabled={isLoadingOptions || options.length === 0}
684
+ >
685
+ {options.length === 0 ? (
686
+ <option value="">{t('integrations.marketplace.noResults', 'No integrations found')}</option>
687
+ ) : null}
688
+ {options.map((item) => (
689
+ <option key={item.integrationId} value={item.integrationId}>
690
+ {item.title}
691
+ </option>
692
+ ))}
693
+ </select>
694
+ </div>
695
+ <div className="space-y-2">
696
+ <Label className="flex items-center gap-2 text-sm font-medium">
697
+ <Boxes className="size-4 text-muted-foreground" />
698
+ <span>{t('data_sync.dashboard.columns.entityType')}</span>
699
+ </Label>
700
+ <select
701
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
702
+ value={selectedEntityType}
703
+ onChange={(event) => setSelectedEntityType(event.target.value)}
704
+ disabled={entityOptions.length === 0}
705
+ >
706
+ {entityOptions.map((entityType) => (
707
+ <option key={entityType} value={entityType}>
708
+ {formatEntityTypeLabel(entityType)}
709
+ </option>
710
+ ))}
711
+ </select>
712
+ </div>
713
+ <div className="space-y-2">
714
+ <Label className="flex items-center gap-2 text-sm font-medium">
715
+ <ArrowRightLeft className="size-4 text-muted-foreground" />
716
+ <span>{t('data_sync.dashboard.columns.direction')}</span>
717
+ </Label>
718
+ <select
719
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
720
+ value={selectedDirection}
721
+ onChange={(event) => setSelectedDirection(event.target.value === 'export' ? 'export' : 'import')}
722
+ disabled={selectedIntegration?.direction !== 'bidirectional'}
723
+ >
724
+ <option value="import">{t('data_sync.dashboard.direction.import')}</option>
725
+ {(selectedIntegration?.direction === 'bidirectional' || selectedIntegration?.direction === 'export') ? (
726
+ <option value="export">{t('data_sync.dashboard.direction.export')}</option>
727
+ ) : null}
728
+ </select>
729
+ </div>
730
+ </div>
731
+
732
+ {selectedIntegration?.description ? (
733
+ <p className="text-sm text-muted-foreground">{selectedIntegration.description}</p>
734
+ ) : null}
735
+
736
+ <div className="grid gap-4 xl:grid-cols-2">
737
+ <div className="rounded-xl border bg-muted/20 p-4">
738
+ <div className="flex items-start justify-between gap-3">
739
+ <div className="space-y-1">
740
+ <div className="flex items-center gap-2">
741
+ <Play className="size-4 text-primary" />
742
+ <h3 className="text-sm font-semibold">{t('data_sync.dashboard.start.runNowTitle', 'Run once now')}</h3>
743
+ </div>
744
+ <p className="text-sm text-muted-foreground">
745
+ {t('data_sync.dashboard.start.runNowDescription', 'Use this for the next immediate sync. Batch size and full-sync mode apply only to this manual run.')}
746
+ </p>
747
+ </div>
748
+ <Badge variant="outline">{selectedEntityLabel}</Badge>
749
+ </div>
750
+
751
+ <Separator className="my-4" />
752
+
753
+ <div className="grid gap-4 sm:grid-cols-[minmax(0,180px)_1fr]">
754
+ <div className="space-y-2">
755
+ <Label className="flex items-center gap-2 text-sm font-medium">
756
+ <Gauge className="size-4 text-muted-foreground" />
757
+ <span>{t('data_sync.dashboard.start.batchSize', 'Batch size')}</span>
758
+ </Label>
759
+ <Input
760
+ value={batchSize}
761
+ onChange={(event) => setBatchSize(event.target.value)}
762
+ inputMode="numeric"
763
+ />
764
+ </div>
765
+ <div className="rounded-lg border bg-background p-3">
766
+ <div className="flex items-center justify-between gap-3">
767
+ <div className="space-y-1">
768
+ <Label className="text-sm font-medium">{t('data_sync.dashboard.start.fullSync', 'Run as full sync')}</Label>
769
+ <p className="text-xs text-muted-foreground">
770
+ {t('data_sync.dashboard.start.fullSyncHelp', 'Ignore the saved cursor and process the entire source again for this run.')}
771
+ </p>
772
+ </div>
773
+ <Switch checked={fullSync} onCheckedChange={setFullSync} />
774
+ </div>
775
+ </div>
776
+ </div>
777
+
778
+ <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
779
+ <p className="text-xs text-muted-foreground">
780
+ {t('data_sync.dashboard.start.runNowFootnote', 'Manual runs show progress immediately and land on the run detail page after launch.')}
781
+ </p>
782
+ <Button
783
+ type="button"
784
+ onClick={() => void handleStartSync()}
785
+ disabled={!canStartSelectedIntegration}
786
+ >
787
+ <Play className="mr-2 size-4" />
788
+ {t('data_sync.dashboard.start.submit', 'Start sync')}
789
+ </Button>
790
+ </div>
791
+ </div>
792
+
793
+ <div className="rounded-xl border bg-muted/20 p-4">
794
+ <div className="flex items-start justify-between gap-3">
795
+ <div className="space-y-1">
796
+ <div className="flex items-center gap-2">
797
+ <CalendarClock className="size-4 text-primary" />
798
+ <h3 className="text-sm font-semibold">{t('data_sync.dashboard.schedule.title', 'Recurring schedule')}</h3>
799
+ </div>
800
+ <p className="text-sm text-muted-foreground">
801
+ {t('data_sync.dashboard.schedule.description', 'Save a repeating schedule for the selected integration, entity, and direction without leaving this dashboard.')}
802
+ </p>
803
+ </div>
804
+ <Badge variant="outline">
805
+ {hasSavedSchedule
806
+ ? (scheduleEditor.isEnabled
807
+ ? t('data_sync.dashboard.schedule.status.shortEnabled', 'Scheduled')
808
+ : t('data_sync.dashboard.schedule.status.shortDisabled', 'Paused'))
809
+ : t('data_sync.dashboard.schedule.status.shortNone', 'One-time only')}
810
+ </Badge>
811
+ </div>
812
+
813
+ <Separator className="my-4" />
814
+
815
+ <div className="grid gap-4 sm:grid-cols-2">
816
+ <div className="space-y-2">
817
+ <Label className="flex items-center gap-2 text-sm font-medium">
818
+ <Clock3 className="size-4 text-muted-foreground" />
819
+ <span>{t('data_sync.dashboard.schedule.type', 'Schedule type')}</span>
820
+ </Label>
821
+ <select
822
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
823
+ value={scheduleEditor.scheduleType}
824
+ onChange={(event) => updateScheduleEditor({
825
+ scheduleType: event.target.value === 'cron' ? 'cron' : 'interval',
826
+ })}
827
+ disabled={isLoadingSchedule || isSavingSchedule || isDeletingSchedule || !selectedIntegration || !selectedEntityType}
828
+ >
829
+ <option value="interval">{t('data_sync.dashboard.schedule.interval', 'Interval')}</option>
830
+ <option value="cron">{t('data_sync.dashboard.schedule.cron', 'Cron')}</option>
831
+ </select>
832
+ </div>
833
+ <div className="space-y-2">
834
+ <Label className="flex items-center gap-2 text-sm font-medium">
835
+ <CalendarClock className="size-4 text-muted-foreground" />
836
+ <span>
837
+ {scheduleEditor.scheduleType === 'cron'
838
+ ? t('data_sync.dashboard.schedule.cronValue', 'Cron expression')
839
+ : t('data_sync.dashboard.schedule.intervalValue', 'Interval')}
840
+ </span>
841
+ </Label>
842
+ <Input
843
+ value={scheduleEditor.scheduleValue}
844
+ onChange={(event) => updateScheduleEditor({ scheduleValue: event.target.value })}
845
+ disabled={isLoadingSchedule || isSavingSchedule || isDeletingSchedule || !selectedIntegration || !selectedEntityType}
846
+ placeholder={scheduleEditor.scheduleType === 'cron' ? '0 * * * *' : '1h'}
847
+ />
848
+ <p className="text-xs text-muted-foreground">
849
+ {scheduleEditor.scheduleType === 'cron'
850
+ ? t('data_sync.dashboard.schedule.cronHelp', 'Example: `0 * * * *` runs at the start of every hour.')
851
+ : t('data_sync.dashboard.schedule.intervalHelp', 'Example: `1h`, `6h`, or `24h` for repeating intervals.')}
852
+ </p>
853
+ </div>
854
+ <div className="space-y-2 sm:col-span-2">
855
+ <Label className="flex items-center gap-2 text-sm font-medium">
856
+ <Clock3 className="size-4 text-muted-foreground" />
857
+ <span>{t('data_sync.dashboard.schedule.timezone', 'Timezone')}</span>
858
+ </Label>
859
+ <Input
860
+ value={scheduleEditor.timezone}
861
+ onChange={(event) => updateScheduleEditor({ timezone: event.target.value })}
862
+ disabled={isLoadingSchedule || isSavingSchedule || isDeletingSchedule || !selectedIntegration || !selectedEntityType}
863
+ />
864
+ </div>
865
+ </div>
866
+
867
+ <div className="mt-4 grid gap-3">
868
+ <div className="rounded-lg border bg-background p-3">
869
+ <div className="flex items-center justify-between gap-3">
870
+ <div className="space-y-1">
871
+ <Label className="text-sm font-medium">{t('data_sync.dashboard.schedule.fullSync', 'Run scheduled jobs as full sync')}</Label>
872
+ <p className="text-xs text-muted-foreground">
873
+ {t('data_sync.dashboard.schedule.fullSyncHelp', 'When enabled, every recurring run starts from the beginning instead of the saved cursor.')}
874
+ </p>
875
+ </div>
876
+ <Switch
877
+ checked={scheduleEditor.fullSync}
878
+ onCheckedChange={(checked) => updateScheduleEditor({ fullSync: checked })}
879
+ disabled={isLoadingSchedule || isSavingSchedule || isDeletingSchedule || !selectedIntegration || !selectedEntityType}
880
+ />
881
+ </div>
882
+ </div>
883
+ <div className="rounded-lg border bg-background p-3">
884
+ <div className="flex items-center justify-between gap-3">
885
+ <div className="space-y-1">
886
+ <Label className="text-sm font-medium">{t('data_sync.dashboard.schedule.enabled', 'Schedule enabled')}</Label>
887
+ <p className="text-xs text-muted-foreground">
888
+ {t('data_sync.dashboard.schedule.enabledHelp', 'Pause the recurring job without deleting the schedule definition.')}
889
+ </p>
890
+ </div>
891
+ <Switch
892
+ checked={scheduleEditor.isEnabled}
893
+ onCheckedChange={(checked) => updateScheduleEditor({ isEnabled: checked })}
894
+ disabled={isLoadingSchedule || isSavingSchedule || isDeletingSchedule || !selectedIntegration || !selectedEntityType}
895
+ />
896
+ </div>
897
+ </div>
898
+ </div>
899
+
900
+ <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
901
+ <div className="space-y-1 text-xs text-muted-foreground">
902
+ <div>
903
+ {hasSavedSchedule
904
+ ? (scheduleEditor.lastRunAt
905
+ ? t('data_sync.dashboard.schedule.lastRun', 'Last scheduled run: {value}', {
906
+ value: new Date(scheduleEditor.lastRunAt).toLocaleString(),
907
+ })
908
+ : t('data_sync.dashboard.schedule.neverRun', 'Saved, but no scheduled execution has completed yet.'))
909
+ : t('data_sync.dashboard.schedule.none', 'No recurring schedule saved for this target yet.')}
910
+ </div>
911
+ </div>
912
+ <div className="flex flex-wrap gap-2">
913
+ <Button
914
+ type="button"
915
+ variant="outline"
916
+ onClick={() => void handleDeleteSchedule()}
917
+ disabled={!hasSavedSchedule || isDeletingSchedule}
918
+ >
919
+ {isDeletingSchedule
920
+ ? t('data_sync.dashboard.schedule.deleting', 'Removing...')
921
+ : t('data_sync.dashboard.schedule.delete', 'Remove schedule')}
922
+ </Button>
923
+ <Button
924
+ type="button"
925
+ variant="outline"
926
+ onClick={() => void handleSaveSchedule()}
927
+ disabled={isSavingSchedule || !selectedIntegration || !selectedEntityType}
928
+ >
929
+ <CalendarClock className="mr-2 size-4" />
930
+ {isSavingSchedule
931
+ ? t('data_sync.dashboard.schedule.saving', 'Saving...')
932
+ : t('data_sync.dashboard.schedule.save', 'Save recurring schedule')}
933
+ </Button>
934
+ </div>
935
+ </div>
936
+ </div>
937
+ </div>
938
+
939
+ {selectedIntegration && !selectedIntegration.isEnabled ? (
940
+ <Notice compact variant="warning">
941
+ <span className="inline-flex items-center gap-2">
942
+ <CircleAlert className="size-4" />
943
+ <span>{t('integrations.detail.state.disabled', 'This integration is disabled. Enable it on the integration settings page before starting a sync.')}</span>
944
+ </span>
945
+ </Notice>
946
+ ) : null}
947
+ {selectedIntegration && !selectedIntegration.hasCredentials ? (
948
+ <Notice compact variant="warning">
949
+ <span className="inline-flex items-center gap-2">
950
+ <CircleAlert className="size-4" />
951
+ <span>{t('integrations.detail.credentials.notConfigured', 'Credentials are not configured yet. Save the integration credentials before starting a sync.')}</span>
952
+ </span>
953
+ </Notice>
954
+ ) : null}
955
+ </CardContent>
956
+ </Card>
957
+
204
958
  <DataTable
205
959
  title={t('data_sync.dashboard.title')}
206
960
  columns={columns}