CI / quality (push) Waiting to run
- Add src/core/ (config, errors, events, http, observability, storage, query) - Add src/Spreadsheet/ engine with native POI backend and Excel bridge - Add Envelope C spreadsheet flow and tender bidding envelope modules - Consolidate components into src/components/, remove legacy Component/ and Hooks/ - Add registration state/validation, auth session guard, profile edit modules - Add unit tests across axios, redux, bidding, registration, encryption, core - Add CI workflow, docs (architecture, audit, DSC API spec), keystore example - Remove tracked keystore binary; ignore scripts.zip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
View,
|
|
FlatList,
|
|
StyleSheet,
|
|
BackHandler,
|
|
} from 'react-native';
|
|
import LottieView from 'lottie-react-native';
|
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
|
import { InsideHeader } from '../../components';
|
|
import { useWebSocketService } from '../../socket/Socket';
|
|
import Variables from '../../Constant/Variable';
|
|
import { useSelector } from 'react-redux';
|
|
import { apiCall } from '../../Axios/Axios';
|
|
import {
|
|
AUCTION_ONE_TIME_BID,
|
|
GET_AUCTION_DETAILS,
|
|
SELECTED_AUCTION_ITEM,
|
|
} from '../../Services/ApiCall/ApiUrls';
|
|
import AuctionBiddingCard from './Component/AuctionBiddingCard';
|
|
import ConfirmBidModal from './Component/ConfirmBidModal';
|
|
import GoBackModal from './Component/GoBackModal';
|
|
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native';
|
|
import NavigationString from '../../Constant/NavigationString';
|
|
import CountdownTimer from '../../components/CountdownTimer';
|
|
import { useTheme } from '../../Hooks/useTheme';
|
|
import AppText from '../../components/AppText';
|
|
import { Gavel, Building2, Clock4 } from 'lucide-react-native';
|
|
import { showErrorAlert, showSuccessAlert } from '../../Constant/ShowDailog';
|
|
import { logger } from '../../core/observability/logger';
|
|
import { getBiddingConfig } from './biddingPattern';
|
|
|
|
/**
|
|
* THE bidding panel — serves both auction patterns. The pattern comes from
|
|
* the selected auction; every forward/reverse difference lives in
|
|
* biddingPattern.ts, never in this file.
|
|
*
|
|
* Registered under both the StartBidding and StartReverseBidding routes so
|
|
* existing navigation keeps working unchanged.
|
|
*/
|
|
const AuctionBiddingScreen = () => {
|
|
const { colors, spacing, shadows } = useTheme();
|
|
const SelectedAuction = useSelector((state: any) => state.auction.auction);
|
|
const loginUser = useSelector((state: any) => state.token.token);
|
|
const Navigation: any = useNavigation();
|
|
const config = useMemo(
|
|
() => getBiddingConfig(SelectedAuction?.auctionPattern),
|
|
[SelectedAuction?.auctionPattern],
|
|
);
|
|
|
|
const [items, setItems] = useState<any>([]);
|
|
const [bidValues, setBidValues] = useState<Record<number, number>>({});
|
|
const [allBoolean, setAllBoolean] = useState({
|
|
showSuccess: false,
|
|
showDialog: false,
|
|
showBackDialog: false,
|
|
bidPayload: {} as any,
|
|
oneTimeBid: false,
|
|
rebid: false,
|
|
reload: false,
|
|
});
|
|
|
|
const { subscribe, unsubscribe, disconnect, createRoom, placeBid } = useWebSocketService(
|
|
Variables.webSocketUrl,
|
|
() => logger.info('stomp', 'connected'),
|
|
(error: any) => logger.warn('stomp', `connection error: ${error}`),
|
|
);
|
|
|
|
const getAuction = async () => {
|
|
const selectedRes = await apiCall<any>('get', GET_AUCTION_DETAILS, {}, { id: SelectedAuction.auctionId });
|
|
if (selectedRes.statusCode === 200 && selectedRes.data.contractorId.length) {
|
|
setAllBoolean(prev => ({ ...prev, oneTimeBid: true }));
|
|
}
|
|
};
|
|
|
|
const getOneTimeBidValue = async () => {
|
|
try {
|
|
const selectedRes = await apiCall<any>('get', AUCTION_ONE_TIME_BID, {}, { auctionId: SelectedAuction.auctionId });
|
|
return selectedRes.statusCode === 200 ? selectedRes.data : [];
|
|
} catch { return []; }
|
|
};
|
|
|
|
const getAllAcutionItems = async () => {
|
|
try {
|
|
const payload = { auctionNumber: SelectedAuction.auctionNumber };
|
|
let auctionItem: any[] = [];
|
|
|
|
const res = await apiCall<any>('get', config.itemsUrl, {}, payload);
|
|
if (res?.statusCode === 200) { auctionItem = res.data || []; }
|
|
|
|
const selectedRes = await apiCall<any>('get', SELECTED_AUCTION_ITEM, {}, payload);
|
|
const uniqueArray: number[] = Array.from(new Set(selectedRes?.data || []));
|
|
const filteredItems = auctionItem.filter(item => uniqueArray.includes(item.id));
|
|
|
|
if (SelectedAuction.auctionCategories === 'Live') {
|
|
const result = await createRoom(SelectedAuction.auctionNumber);
|
|
const liveBids = new Map<number, { bidAmount: number; contractorId: number }>();
|
|
(result || []).forEach((r: any) => {
|
|
liveBids.set(r.auctionItemId, { bidAmount: r.bidAmount, contractorId: r.contractorId });
|
|
});
|
|
const updatedFilteredItems = filteredItems.map(item => {
|
|
const bidData = liveBids.get(item.id);
|
|
return {
|
|
...item,
|
|
bidAmount: bidData?.bidAmount ?? config.startValue(item),
|
|
bidAvilable: bidData?.bidAmount ?? 0,
|
|
me: bidData?.contractorId === loginUser.contractorId,
|
|
};
|
|
});
|
|
const initialBids: Record<number, number> = {};
|
|
updatedFilteredItems.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
|
setBidValues(initialBids);
|
|
setItems(updatedFilteredItems);
|
|
} else {
|
|
if (SelectedAuction.rebidAllowed) { setAllBoolean(prev => ({ ...prev, rebid: true })); }
|
|
const result: any[] = await getOneTimeBidValue();
|
|
const bidMap = new Map<number, any>();
|
|
result.forEach((bid: any) => bidMap.set(bid.auctionItemId, bid));
|
|
const mergedData = auctionItem.map((item: any) => {
|
|
const bid = bidMap.get(item.id);
|
|
if (!bid) { return { ...item, bidAmount: config.startValue(item), contractorId: item.contractorId }; }
|
|
return {
|
|
...item,
|
|
bidAmount: bid.bidAmount,
|
|
bidId: bid.bidId,
|
|
contractorId: bid.contractorId,
|
|
contractorName: bid.contractorName,
|
|
me: bid.contractorId === loginUser.contractorId,
|
|
bidTime: bid.bidTime,
|
|
};
|
|
});
|
|
const initialBids: Record<number, number> = {};
|
|
mergedData.forEach(item => { initialBids[item.id] = item.bidAmount; });
|
|
setBidValues(initialBids);
|
|
setItems(mergedData);
|
|
}
|
|
} catch (error) {
|
|
logger.error('bidding', 'fetching auction items failed', { error: String(error) });
|
|
}
|
|
};
|
|
|
|
// Live bid updates: subscribe once per room from an effect (never the render
|
|
// body) and tear the subscription down when the room changes or we unmount.
|
|
useEffect(() => {
|
|
const destination = `/topic/room/${SelectedAuction.auctionNumber}`;
|
|
subscribe(destination, (message: any) => {
|
|
const result = message[message.length - 1];
|
|
setItems((prev: any[]) =>
|
|
prev.map(item =>
|
|
item.id === result.auctionItemId
|
|
? { ...item, bidAmount: result.bidAmount, me: result.contractorId === loginUser.contractorId }
|
|
: item,
|
|
),
|
|
);
|
|
setBidValues(prev => ({ ...prev, [result.auctionItemId]: result.bidAmount }));
|
|
});
|
|
return () => unsubscribe(destination);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [SelectedAuction.auctionNumber]);
|
|
|
|
// Fully close the STOMP connection on unmount — a leaked client keeps
|
|
// reconnecting every 5s and drains battery/data.
|
|
const disconnectRef = useRef(disconnect);
|
|
disconnectRef.current = disconnect;
|
|
useEffect(() => () => disconnectRef.current(), []);
|
|
|
|
useEffect(() => {
|
|
if (SelectedAuction.auctionCategories === 'One-time') { getAuction(); }
|
|
getAllAcutionItems();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [allBoolean.reload]);
|
|
|
|
const handleUpdate = (id: number, val: number) => {
|
|
setBidValues(prev => ({ ...prev, [id]: prev[id] + val }));
|
|
};
|
|
|
|
const submitBid = async (item: any) => {
|
|
const payload = {
|
|
itemId: item.id,
|
|
bidAmount: bidValues[item.id],
|
|
auctionId: SelectedAuction.auctionId,
|
|
contractorId: loginUser.contractorId,
|
|
fullName: loginUser.fullName,
|
|
roomId: SelectedAuction.auctionNumber,
|
|
};
|
|
setAllBoolean(prev => ({ ...prev, bidPayload: payload, showDialog: true }));
|
|
};
|
|
|
|
const placeBidSubmit = async (data: any) => {
|
|
setAllBoolean(prev => ({ ...prev, showDialog: false }));
|
|
try {
|
|
const result = await placeBid(data);
|
|
if (result?.status && result.data) {
|
|
if (result.message) { showSuccessAlert(result.message); }
|
|
getAllAcutionItems();
|
|
} else if (result?.message) {
|
|
showErrorAlert(result.message);
|
|
}
|
|
} catch (error) {
|
|
logger.error('bidding', 'placing bid failed', { error: String(error) });
|
|
}
|
|
};
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
const onBackPress = () => {
|
|
setAllBoolean(prev => ({ ...prev, showBackDialog: true }));
|
|
return true;
|
|
};
|
|
const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress);
|
|
return () => sub.remove();
|
|
}, []),
|
|
);
|
|
|
|
const styles = makeStyles(colors, spacing, shadows);
|
|
|
|
const biddingEndDate =
|
|
SelectedAuction?.auctionStatus === 'Amended'
|
|
? SelectedAuction?.amendedkeyDates?.auctionBidding?.endDateTime
|
|
: SelectedAuction?.keyDates?.auctionBidding?.endDateTime;
|
|
|
|
return (
|
|
<View style={styles.root}>
|
|
<InsideHeader title="Bidding Panel" showArrow />
|
|
|
|
{/* ── Top Info Band ── */}
|
|
<Animated.View entering={FadeInDown.springify()} style={styles.infoBand}>
|
|
{/* Department row */}
|
|
<View style={styles.deptRow}>
|
|
<View style={[styles.deptIcon, { backgroundColor: `${colors.primary}15` }]}>
|
|
<Building2 size={16} color={colors.primary} strokeWidth={2} />
|
|
</View>
|
|
<AppText variant="bodySmall" color={colors.text} bold numberOfLines={1} style={styles.deptName as any}>
|
|
{SelectedAuction.deptName ?? 'Auction Department'}
|
|
</AppText>
|
|
</View>
|
|
|
|
{/* Timer card */}
|
|
<View style={[styles.timerCard, { backgroundColor: `${colors.primary}10`, borderColor: `${colors.primary}25` }]}>
|
|
<View style={styles.timerLabelRow}>
|
|
<Clock4 size={13} color={colors.primary} strokeWidth={2} />
|
|
<AppText variant="caption" color={colors.primary} bold style={styles.timerLabel as any}>
|
|
BIDDING ENDS IN
|
|
</AppText>
|
|
</View>
|
|
<CountdownTimer
|
|
endDate={biddingEndDate}
|
|
myStyle={{ fontSize: 28, fontWeight: '900', color: colors.primary, letterSpacing: 1 }}
|
|
size={28}
|
|
/>
|
|
</View>
|
|
|
|
{/* Auction chip */}
|
|
<View style={styles.chipRow}>
|
|
<View style={[styles.chip, { backgroundColor: `${colors.primary}12` }]}>
|
|
<Gavel size={12} color={colors.primary} strokeWidth={2} />
|
|
<AppText variant="caption" color={colors.primary} bold style={styles.chipText as any}>
|
|
{SelectedAuction.auctionNumber}
|
|
</AppText>
|
|
</View>
|
|
<View style={[styles.chip, { backgroundColor: SelectedAuction.auctionCategories === 'Live' ? `${colors.success}15` : `${colors.textSecondary}12` }]}>
|
|
<View style={[styles.liveDot, { backgroundColor: SelectedAuction.auctionCategories === 'Live' ? colors.success : colors.textSecondary }]} />
|
|
<AppText
|
|
variant="caption"
|
|
color={SelectedAuction.auctionCategories === 'Live' ? colors.success : colors.textSecondary}
|
|
bold
|
|
style={styles.chipText as any}
|
|
>
|
|
{SelectedAuction.auctionCategories ?? 'Auction'}
|
|
</AppText>
|
|
</View>
|
|
</View>
|
|
</Animated.View>
|
|
|
|
{/* ── Items List ── */}
|
|
<FlatList
|
|
data={items}
|
|
keyExtractor={item => item.id.toString()}
|
|
renderItem={({ item, index }) => (
|
|
<AuctionBiddingCard
|
|
item={{ ...item, index }}
|
|
bidValue={bidValues[item.id] || 0}
|
|
onIncrease={() => handleUpdate(item.id, config.step(item))}
|
|
onDecrease={() => handleUpdate(item.id, -config.step(item))}
|
|
onPlaceBid={() => submitBid(item)}
|
|
SelectedAuction={SelectedAuction}
|
|
allBoolean={allBoolean}
|
|
setAllBoolean={setAllBoolean}
|
|
config={config}
|
|
/>
|
|
)}
|
|
contentContainerStyle={styles.listContent}
|
|
showsVerticalScrollIndicator={false}
|
|
/>
|
|
|
|
{/* ── Success overlay ── */}
|
|
{allBoolean.showSuccess && (
|
|
<View style={styles.overlay}>
|
|
<LottieView
|
|
source={require('../../lottie/Success.json')}
|
|
autoPlay
|
|
loop={false}
|
|
style={styles.successLottie}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
<ConfirmBidModal
|
|
visible={allBoolean.showDialog}
|
|
onClose={() => setAllBoolean(prev => ({ ...prev, showDialog: false }))}
|
|
onConfirm={() => placeBidSubmit(allBoolean.bidPayload)}
|
|
bidAmount={allBoolean.bidPayload?.bidAmount}
|
|
/>
|
|
|
|
<GoBackModal
|
|
visible={allBoolean.showBackDialog}
|
|
onClose={() => setAllBoolean(prev => ({ ...prev, showBackDialog: false }))}
|
|
onConfirm={() => {
|
|
setAllBoolean(prev => ({ ...prev, showBackDialog: false }));
|
|
Navigation.dispatch(
|
|
CommonActions.reset({
|
|
index: 0,
|
|
routes: [{ name: NavigationString.Home }],
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default AuctionBiddingScreen;
|
|
|
|
/* ─── Styles factory ─── */
|
|
const makeStyles = (colors: any, spacing: any, shadows: any) =>
|
|
StyleSheet.create({
|
|
root: {
|
|
flex: 1,
|
|
backgroundColor: colors.background,
|
|
},
|
|
|
|
/* Info band */
|
|
infoBand: {
|
|
backgroundColor: colors.surface,
|
|
paddingHorizontal: spacing.m,
|
|
paddingVertical: spacing.m,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
gap: spacing.s,
|
|
...shadows.light,
|
|
},
|
|
deptRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.s,
|
|
},
|
|
deptIcon: {
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 9,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
deptName: {
|
|
flex: 1,
|
|
} as any,
|
|
|
|
/* Timer */
|
|
timerCard: {
|
|
borderRadius: 16,
|
|
borderWidth: 1.2,
|
|
paddingVertical: spacing.m,
|
|
paddingHorizontal: spacing.m,
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
timerLabelRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
timerLabel: {
|
|
letterSpacing: 1.2,
|
|
} as any,
|
|
|
|
/* Chips */
|
|
chipRow: {
|
|
flexDirection: 'row',
|
|
gap: spacing.s,
|
|
},
|
|
chip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
paddingHorizontal: spacing.s + 4,
|
|
paddingVertical: 5,
|
|
borderRadius: 20,
|
|
},
|
|
chipText: {
|
|
letterSpacing: 0.3,
|
|
} as any,
|
|
liveDot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
},
|
|
|
|
listContent: {
|
|
padding: spacing.m,
|
|
paddingBottom: 120,
|
|
},
|
|
|
|
overlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: colors.overlay,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
zIndex: 99,
|
|
},
|
|
successLottie: { width: 200, height: 200 },
|
|
});
|