@@ -25,6 +25,299 @@ import {
2525 exportBatchResultToCsv as exportCsv ,
2626} from '../stores/batchStore' ;
2727import { colors , spacing , typography , borderRadius } from '../../src/utils/constants' ;
28+ import {
29+ useAtomicBatch ,
30+ validateAtomicBatch ,
31+ type AtomicBatchItem ,
32+ type AtomicBatchReport ,
33+ type AtomicBatchStatus ,
34+ } from '../services/atomicBatchService' ;
35+
36+ // ── Atomic status colour map ──────────────────────────────────────────────
37+
38+ const ATOMIC_STATUS_COLORS : Record < AtomicBatchStatus , string > = {
39+ idle : colors . textSecondary ,
40+ validating : colors . warning ,
41+ snapshotting : colors . warning ,
42+ executing : colors . primary ,
43+ committing : colors . primary ,
44+ rolling_back : colors . error ,
45+ committed : colors . success ,
46+ rolled_back : colors . error ,
47+ failed : colors . error ,
48+ } ;
49+
50+ // ── AtomicExecutionPanel ──────────────────────────────────────────────────
51+
52+ interface AtomicExecutionPanelProps {
53+ operationType : BatchOperationType ;
54+ subscriptionIds : string [ ] ;
55+ }
56+
57+ const AtomicExecutionPanel : React . FC < AtomicExecutionPanelProps > = ( {
58+ operationType,
59+ subscriptionIds,
60+ } ) => {
61+ const { runAtomic, isBusy } = useAtomicBatch ( ) ;
62+ const [ report , setReport ] = React . useState < AtomicBatchReport | null > ( null ) ;
63+ const [ status , setStatus ] = React . useState < AtomicBatchStatus > ( 'idle' ) ;
64+ const [ validationErrors , setValidationErrors ] = React . useState < string [ ] > ( [ ] ) ;
65+
66+ const items : AtomicBatchItem [ ] = subscriptionIds . map ( ( sid , idx ) => ( {
67+ id : `item_${ idx } ` ,
68+ subscriptionId : sid ,
69+ operation : operationType ,
70+ payload : { } ,
71+ } ) ) ;
72+
73+ const handleRunAtomic = React . useCallback ( async ( ) => {
74+ setValidationErrors ( [ ] ) ;
75+ const validation = validateAtomicBatch ( items ) ;
76+ if ( ! validation . valid ) {
77+ setValidationErrors ( validation . errors ) ;
78+ return ;
79+ }
80+ setStatus ( 'executing' ) ;
81+ try {
82+ const result = await runAtomic ( `batch_${ Date . now ( ) } ` , items , {
83+ failFast : true ,
84+ concurrency : 1 ,
85+ timeoutPerItemMs : 10_000 ,
86+ } ) ;
87+ setReport ( result ) ;
88+ setStatus ( result . status ) ;
89+ } catch {
90+ setStatus ( 'failed' ) ;
91+ }
92+ } , [ items , runAtomic ] ) ;
93+
94+ const handleReset = ( ) => {
95+ setReport ( null ) ;
96+ setStatus ( 'idle' ) ;
97+ setValidationErrors ( [ ] ) ;
98+ } ;
99+
100+ const statusColor = ATOMIC_STATUS_COLORS [ status ] ;
101+
102+ return (
103+ < View style = { atomicStyles . panel } >
104+ < View style = { atomicStyles . panelHeader } >
105+ < Text style = { atomicStyles . panelTitle } > ⚛ Atomic Execution</ Text >
106+ < View style = { [ atomicStyles . statusBadge , { backgroundColor : statusColor + '22' , borderColor : statusColor } ] } >
107+ < Text style = { [ atomicStyles . statusText , { color : statusColor } ] } >
108+ { status . toUpperCase ( ) . replace ( '_' , ' ' ) }
109+ </ Text >
110+ </ View >
111+ </ View >
112+
113+ < Text style = { atomicStyles . description } >
114+ Atomic mode executes all { subscriptionIds . length } item
115+ { subscriptionIds . length !== 1 ? 's' : '' } as a single unit. Any failure
116+ will automatically roll back all previously applied changes.
117+ </ Text >
118+
119+ { validationErrors . length > 0 && (
120+ < View style = { atomicStyles . errorBox } >
121+ { validationErrors . map ( ( e , i ) => (
122+ < Text key = { i } style = { atomicStyles . errorText } > • { e } </ Text >
123+ ) ) }
124+ </ View >
125+ ) }
126+
127+ { report && (
128+ < View style = { atomicStyles . reportBox } >
129+ < View style = { atomicStyles . reportRow } >
130+ < Text style = { atomicStyles . reportLabel } > Total items</ Text >
131+ < Text style = { atomicStyles . reportValue } > { report . totalItems } </ Text >
132+ </ View >
133+ < View style = { atomicStyles . reportRow } >
134+ < Text style = { atomicStyles . reportLabel } > Succeeded</ Text >
135+ < Text style = { [ atomicStyles . reportValue , { color : colors . success } ] } >
136+ { report . succeededItems }
137+ </ Text >
138+ </ View >
139+ < View style = { atomicStyles . reportRow } >
140+ < Text style = { atomicStyles . reportLabel } > Failed</ Text >
141+ < Text style = { [ atomicStyles . reportValue , { color : colors . error } ] } >
142+ { report . failedItems }
143+ </ Text >
144+ </ View >
145+ { report . rolledBackItems > 0 && (
146+ < View style = { atomicStyles . reportRow } >
147+ < Text style = { atomicStyles . reportLabel } > Rolled back</ Text >
148+ < Text style = { [ atomicStyles . reportValue , { color : colors . warning } ] } >
149+ { report . rolledBackItems }
150+ </ Text >
151+ </ View >
152+ ) }
153+ { report . durationMs !== undefined && (
154+ < View style = { atomicStyles . reportRow } >
155+ < Text style = { atomicStyles . reportLabel } > Duration</ Text >
156+ < Text style = { atomicStyles . reportValue } >
157+ { report . durationMs < 1000
158+ ? `${ report . durationMs } ms`
159+ : `${ ( report . durationMs / 1000 ) . toFixed ( 2 ) } s` }
160+ </ Text >
161+ </ View >
162+ ) }
163+ { report . rollbackReason && (
164+ < View style = { atomicStyles . rollbackBox } >
165+ < Text style = { atomicStyles . rollbackLabel } > Rollback reason:</ Text >
166+ < Text style = { atomicStyles . rollbackReason } > { report . rollbackReason } </ Text >
167+ </ View >
168+ ) }
169+ < Text style = { atomicStyles . idempotencyKey } >
170+ Key: { report . idempotencyKey }
171+ </ Text >
172+ </ View >
173+ ) }
174+
175+ < View style = { atomicStyles . buttonRow } >
176+ { status === 'idle' || status === 'failed' || status === 'committed' || status === 'rolled_back' ? (
177+ < >
178+ < TouchableOpacity
179+ style = { [
180+ atomicStyles . runButton ,
181+ ( isBusy || subscriptionIds . length === 0 ) && atomicStyles . disabledButton ,
182+ ] }
183+ onPress = { handleRunAtomic }
184+ disabled = { isBusy || subscriptionIds . length === 0 } >
185+ < Text style = { atomicStyles . runButtonText } >
186+ { status === 'idle' ? '▶ Run Atomically' : '↺ Re-run' }
187+ </ Text >
188+ </ TouchableOpacity >
189+ { report && (
190+ < TouchableOpacity style = { atomicStyles . resetButton } onPress = { handleReset } >
191+ < Text style = { atomicStyles . resetButtonText } > Reset</ Text >
192+ </ TouchableOpacity >
193+ ) }
194+ </ >
195+ ) : (
196+ < ActivityIndicator color = { colors . primary } />
197+ ) }
198+ </ View >
199+ </ View >
200+ ) ;
201+ } ;
202+
203+ // ── Styles for AtomicExecutionPanel ───────────────────────────────────────
204+
205+ const atomicStyles = StyleSheet . create ( {
206+ panel : {
207+ margin : spacing . md ,
208+ padding : spacing . md ,
209+ backgroundColor : colors . surface ,
210+ borderRadius : borderRadius . md ,
211+ borderWidth : 1 ,
212+ borderColor : colors . primary + '44' ,
213+ } ,
214+ panelHeader : {
215+ flexDirection : 'row' ,
216+ alignItems : 'center' ,
217+ justifyContent : 'space-between' ,
218+ marginBottom : spacing . sm ,
219+ } ,
220+ panelTitle : {
221+ ...typography . h3 ,
222+ color : colors . text ,
223+ fontWeight : '700' ,
224+ } ,
225+ statusBadge : {
226+ paddingHorizontal : spacing . sm ,
227+ paddingVertical : 2 ,
228+ borderRadius : borderRadius . round ,
229+ borderWidth : 1 ,
230+ } ,
231+ statusText : {
232+ ...typography . small ,
233+ fontWeight : '700' ,
234+ letterSpacing : 0.5 ,
235+ } ,
236+ description : {
237+ ...typography . body ,
238+ color : colors . textSecondary ,
239+ marginBottom : spacing . sm ,
240+ } ,
241+ errorBox : {
242+ backgroundColor : colors . error + '18' ,
243+ padding : spacing . sm ,
244+ borderRadius : borderRadius . sm ,
245+ marginBottom : spacing . sm ,
246+ } ,
247+ errorText : {
248+ ...typography . caption ,
249+ color : colors . error ,
250+ } ,
251+ reportBox : {
252+ backgroundColor : colors . surfaceVariant ,
253+ padding : spacing . sm ,
254+ borderRadius : borderRadius . sm ,
255+ marginBottom : spacing . sm ,
256+ gap : spacing . xs ,
257+ } ,
258+ reportRow : {
259+ flexDirection : 'row' ,
260+ justifyContent : 'space-between' ,
261+ } ,
262+ reportLabel : {
263+ ...typography . caption ,
264+ color : colors . textSecondary ,
265+ } ,
266+ reportValue : {
267+ ...typography . caption ,
268+ color : colors . text ,
269+ fontWeight : '600' ,
270+ } ,
271+ rollbackBox : {
272+ marginTop : spacing . xs ,
273+ } ,
274+ rollbackLabel : {
275+ ...typography . small ,
276+ color : colors . error ,
277+ fontWeight : '600' ,
278+ } ,
279+ rollbackReason : {
280+ ...typography . small ,
281+ color : colors . textSecondary ,
282+ } ,
283+ idempotencyKey : {
284+ ...typography . small ,
285+ color : colors . textSecondary ,
286+ marginTop : spacing . xs ,
287+ fontFamily : 'monospace' ,
288+ } ,
289+ buttonRow : {
290+ flexDirection : 'row' ,
291+ gap : spacing . sm ,
292+ marginTop : spacing . sm ,
293+ } ,
294+ runButton : {
295+ flex : 1 ,
296+ backgroundColor : colors . primary ,
297+ paddingVertical : spacing . sm ,
298+ borderRadius : borderRadius . md ,
299+ alignItems : 'center' ,
300+ } ,
301+ runButtonText : {
302+ ...typography . button ,
303+ color : colors . onPrimary ,
304+ } ,
305+ resetButton : {
306+ paddingHorizontal : spacing . md ,
307+ paddingVertical : spacing . sm ,
308+ borderWidth : 1 ,
309+ borderColor : colors . border ,
310+ borderRadius : borderRadius . md ,
311+ alignItems : 'center' ,
312+ } ,
313+ resetButtonText : {
314+ ...typography . button ,
315+ color : colors . text ,
316+ } ,
317+ disabledButton : {
318+ opacity : 0.45 ,
319+ } ,
320+ } ) ;
28321
29322// ════════════════════════════════════════════════════════════════
30323// Constants
@@ -876,6 +1169,20 @@ export const BatchOperationsScreen: React.FC = () => {
8761169 { renderResults ( ) }
8771170 { renderAnalytics ( ) }
8781171
1172+ { /* Issue #919 — Atomic Execution Panel */ }
1173+ < AtomicExecutionPanel
1174+ operationType = { draft . operationType }
1175+ subscriptionIds = {
1176+ draft . csvContent
1177+ ? draft . csvContent
1178+ . split ( '\n' )
1179+ . map ( ( l ) => l . trim ( ) . split ( ',' ) [ 0 ] )
1180+ . filter ( Boolean )
1181+ . slice ( 0 , 100 )
1182+ : [ ]
1183+ }
1184+ />
1185+
8791186 < View style = { styles . bottomPad } />
8801187 </ ScrollView >
8811188
0 commit comments