feat: architecture refactor — core layer, spreadsheet engine, envelope bidding, tests
CI / quality (push) Waiting to run
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9acec328a7
commit
1d7646d938
@@ -1,532 +1,78 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import * as XLSX from 'xlsx';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { useSelector } from 'react-redux';
|
||||
import colors from '../../../Constant/Color';
|
||||
import { GET_ENVELOPE_C, SUBMIT_ENVELOPE_C_UPLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
/**
|
||||
* EnvelopeCExcelPreview — launcher for the native BOQ (Envelope-C) editor.
|
||||
*
|
||||
* The Excel-like editor is a full-screen, virtualized surface, so instead of
|
||||
* embedding a spreadsheet inside the Tender-Bidding scroll view this presents a
|
||||
* compact card that opens the dedicated `EnvelopeCSpreadsheet` screen. Props are
|
||||
* unchanged from the previous inline preview so the call site keeps working:
|
||||
*
|
||||
* <EnvelopeCExcelPreview tenderId={tenderData} buttonEnable />
|
||||
*/
|
||||
import React from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { encrypt, encryptWithRSA } from '../../../Services/Encryption/encrypt';
|
||||
import { FileSpreadsheet, ChevronRight } from 'lucide-react-native';
|
||||
|
||||
import AppText from '../../../components/AppText';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import NavigationString from '../../../Constant/NavigationString';
|
||||
|
||||
/* -------------------- UTILS -------------------- */
|
||||
interface OfficerKey {
|
||||
officersId: number;
|
||||
officerPublicKey: string;
|
||||
officerType: string;
|
||||
interface EnvelopeCExcelPreviewProps {
|
||||
/** The full proceed-to-bid tender data object. */
|
||||
tenderId: any;
|
||||
buttonEnable?: boolean;
|
||||
}
|
||||
|
||||
const EnvelopeCExcelPreview: React.FC<EnvelopeCExcelPreviewProps> = ({ tenderId, buttonEnable }) => {
|
||||
const navigation = useNavigation<any>();
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
interface SaveEnvelopeOfficerDetail {
|
||||
officerId: number;
|
||||
officerEncryptedKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer → Base64
|
||||
const arrayBufferToBase64 = (buffer: ArrayBuffer) => {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return global.btoa(binary);
|
||||
};
|
||||
|
||||
|
||||
// Find column dynamically by keywords
|
||||
const findColumnKey = (columns: string[], keywords: string[]) =>
|
||||
columns.find(col =>
|
||||
keywords.some(k => col.toLowerCase().includes(k.toLowerCase()))
|
||||
);
|
||||
|
||||
|
||||
// Detect TOTAL row (any column contains TOTAL)
|
||||
const isTotalRow = (row: any) =>
|
||||
Object.values(row).some(v => String(v).toUpperCase() === 'TOTAL');
|
||||
|
||||
|
||||
/* -------------------- COMPONENT -------------------- */
|
||||
|
||||
|
||||
const EnvelopeCExcelPreview = (props: any) => {
|
||||
const tenderData = props;
|
||||
console.log("Tender Data", tenderData)
|
||||
const token = useSelector((state: any) => state.token.token);
|
||||
const navigation = useNavigation() as any;
|
||||
|
||||
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columns, setColumns] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadExcelFromApi();
|
||||
}, []);
|
||||
|
||||
|
||||
/* -------------------- LOAD EXCEL -------------------- */
|
||||
|
||||
|
||||
const loadExcelFromApi = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
console.log('\n📊 LOADING EXCEL FROM API');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('🔗 API URL:', `${GET_ENVELOPE_C}${tenderData?.tenderId?.tenderId}`);
|
||||
console.log('🆔 Tender ID:', tenderData?.tenderId?.tenderId);
|
||||
console.log('🔑 Token:', token.token ? 'Present' : 'Missing');
|
||||
|
||||
|
||||
const response = await axios.get(
|
||||
`${GET_ENVELOPE_C}${tenderData?.tenderId?.tenderId}`,
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
Authentication: `Bearer ${token.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
console.log('\n✅ API RESPONSE RECEIVED');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
console.log('📡 Status:', response.status);
|
||||
console.log('📡 Status Text:', response.statusText);
|
||||
console.log('📦 Response Data Size (KB):', (response.data.byteLength / 1024).toFixed(2));
|
||||
|
||||
|
||||
const base64 = arrayBufferToBase64(response.data);
|
||||
const workbook = XLSX.read(base64, { type: 'base64' });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: '' });
|
||||
|
||||
|
||||
console.log('📊 PARSED DATA - Total Rows:', rows.length);
|
||||
|
||||
|
||||
setColumns(Object.keys(rows[0] || {}));
|
||||
setData(rows);
|
||||
|
||||
|
||||
console.log('✅ EXCEL LOADED SUCCESSFULLY\n');
|
||||
|
||||
|
||||
} catch (e: any) {
|
||||
console.log('\n❌ ERROR LOADING EXCEL');
|
||||
console.error('Error:', e.message);
|
||||
Alert.alert('Error', 'Failed to load Excel: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const openEditor = () => {
|
||||
navigation.navigate(NavigationString.EnvelopeCSpreadsheet, {
|
||||
tender: tenderId,
|
||||
buttonEnable: buttonEnable ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/* -------------------- UPDATE CELL -------------------- */
|
||||
|
||||
|
||||
const updateCell = (rowIndex: number, key: string, value: string) => {
|
||||
const updated = [...data];
|
||||
const row = updated[rowIndex];
|
||||
|
||||
|
||||
if (isTotalRow(row)) return;
|
||||
|
||||
|
||||
const qtyKey: any = findColumnKey(columns, ['qty', 'quantity']);
|
||||
const rateKey: any = findColumnKey(columns, ['rate', 'price']);
|
||||
const valueKey = findColumnKey(columns, ['value', 'amount', 'total']);
|
||||
|
||||
|
||||
if (key === qtyKey || key === rateKey) {
|
||||
row[key] = value;
|
||||
|
||||
|
||||
const qty = Number(row[qtyKey]);
|
||||
const rate = Number(row[rateKey]);
|
||||
|
||||
|
||||
if (valueKey) {
|
||||
row[valueKey] =
|
||||
!isNaN(qty) && !isNaN(rate) ? qty * rate : 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Recalculate TOTAL
|
||||
const totalRowIndex = updated.findIndex(isTotalRow);
|
||||
if (totalRowIndex !== -1 && valueKey) {
|
||||
updated[totalRowIndex][valueKey] = updated.reduce((sum, r) => {
|
||||
if (isTotalRow(r)) return sum;
|
||||
const val = Number(r[valueKey]);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
|
||||
setData(updated);
|
||||
};
|
||||
|
||||
|
||||
/* -------------------- SAVE & UPLOAD EXCEL -------------------- */
|
||||
|
||||
|
||||
const buildOfficerEncryptedKeys = (envelopeKey: string): SaveEnvelopeOfficerDetail[] => {
|
||||
const officerKeys: OfficerKey[] = tenderData?.tenderId?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
|
||||
|
||||
|
||||
console.log('\n🔐 ENCRYPTING ENVELOPE KEY FOR OFFICERS');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('📌 Envelope Key to encrypt:', envelopeKey);
|
||||
console.log('👥 Number of officers:', officerKeys.length);
|
||||
|
||||
|
||||
const encryptedKeys: SaveEnvelopeOfficerDetail[] = [];
|
||||
|
||||
|
||||
for (let i = 0; i < officerKeys.length; i++) {
|
||||
const officer = officerKeys[i];
|
||||
console.log(`\n🔄 Officer ${i + 1}/${officerKeys.length}`);
|
||||
console.log(' ID:', officer.officersId);
|
||||
console.log(' Type:', officer.officerType);
|
||||
|
||||
|
||||
try {
|
||||
const encryptedKey = encryptWithRSA(envelopeKey, officer.officerPublicKey);
|
||||
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.error(` ❌ FAILED to encrypt key for officer ${officer.officersId}`);
|
||||
} else {
|
||||
console.log(` ✅ SUCCESS - Encrypted key length: ${encryptedKey.length}`);
|
||||
}
|
||||
|
||||
|
||||
encryptedKeys.push({
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey: encryptedKey || '',
|
||||
officerType: officer.officerType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(` ❌ ERROR encrypting for officer ${officer.officersId}:`, error);
|
||||
encryptedKeys.push({
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey: '',
|
||||
officerType: officer.officerType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log('\n✅ ALL OFFICER KEYS ENCRYPTED');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
return encryptedKeys;
|
||||
};
|
||||
|
||||
|
||||
// ✅ NEW: Encrypt Excel file content (binary) after creation
|
||||
const encryptExcelFile = async (filePath: string, encryptionKey: string): Promise<string> => {
|
||||
console.log('\n🔐 ENCRYPTING EXCEL FILE CONTENT');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log('📁 File Path:', filePath);
|
||||
console.log('🔑 Encryption Key:', encryptionKey);
|
||||
|
||||
|
||||
try {
|
||||
// Read the Excel file as Base64
|
||||
const fileContent = await RNFS.readFile(filePath, 'base64');
|
||||
console.log('📖 File read successfully, Base64 length:', fileContent.length);
|
||||
|
||||
|
||||
// Encrypt the entire file content
|
||||
console.log('🔒 Encrypting file content...');
|
||||
const encryptedContent: any = encrypt(fileContent, encryptionKey);
|
||||
console.log('✅ File content encrypted, Encrypted length:', encryptedContent?.length);
|
||||
|
||||
|
||||
// Save encrypted content to a new file
|
||||
const encryptedFilePath = `${RNFS.DocumentDirectoryPath}/envelopeC_encrypted.xlsx`;
|
||||
await RNFS.writeFile(encryptedFilePath, encryptedContent, 'utf8');
|
||||
console.log('💾 Encrypted file saved at:', encryptedFilePath);
|
||||
|
||||
|
||||
console.log('✅ EXCEL FILE ENCRYPTION COMPLETED');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
|
||||
|
||||
return encryptedFilePath;
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR ENCRYPTING EXCEL FILE:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const saveUpdatedExcel = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
console.log('\n💾 SAVING UPDATED EXCEL');
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
|
||||
|
||||
const ENCRYPTION_KEY = tenderData?.tenderId?.envelopeKeyDetailsResDTO?.envelopeCGenerateKey || '';
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
console.error('❌ ENCRYPTION KEY NOT FOUND');
|
||||
Alert.alert('Error', 'Encryption key not available');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log('🔑 Encryption Key:', ENCRYPTION_KEY);
|
||||
|
||||
|
||||
// Step 1: Create normal Excel workbook with user data (NOT ENCRYPTED YET)
|
||||
console.log('\n📝 STEP 1: Creating Excel workbook with user data');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const worksheet = XLSX.utils.json_to_sheet(data);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
|
||||
|
||||
|
||||
const binary = XLSX.write(workbook, {
|
||||
bookType: 'xlsx',
|
||||
type: 'binary',
|
||||
});
|
||||
|
||||
|
||||
const buffer = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
buffer[i] = binary.charCodeAt(i) & 0xff;
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Save the plain Excel file
|
||||
console.log('\n📝 STEP 2: Saving plain Excel file');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const plainFilePath = `${RNFS.DocumentDirectoryPath}/envelopeC_plain.xlsx`;
|
||||
await RNFS.writeFile(plainFilePath, buffer.toString(), 'ascii');
|
||||
console.log('✅ Plain Excel saved at:', plainFilePath);
|
||||
|
||||
|
||||
// Step 3: Encrypt the entire Excel file
|
||||
console.log('\n📝 STEP 3: Encrypting the Excel file');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const encryptedFilePath = await encryptExcelFile(plainFilePath, ENCRYPTION_KEY);
|
||||
console.log('✅ Encrypted Excel file ready');
|
||||
|
||||
|
||||
// Step 4: Build officer encrypted keys
|
||||
console.log('\n📝 STEP 4: Building officer encrypted keys');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const officerEncryptedKeys = buildOfficerEncryptedKeys(ENCRYPTION_KEY);
|
||||
|
||||
|
||||
// Step 5: Prepare FormData for upload
|
||||
console.log('\n📝 STEP 5: Building FormData');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
const formData = new FormData();
|
||||
const requestDto = {
|
||||
tenderId: tenderData?.tenderId?.tenderId,
|
||||
deptId: tenderData?.tenderId?.deptId,
|
||||
typesOfTender: tenderData?.tenderId?.typesOfTender,
|
||||
fieldValue: '',
|
||||
fieldValueInWord: 'zero',
|
||||
deviceType: 'MOBILE',
|
||||
fileName: 'envelopeC_encrypted.xlsx',
|
||||
saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
|
||||
randomKeyGenerated: ENCRYPTION_KEY,
|
||||
};
|
||||
|
||||
|
||||
formData.append('requestDto', JSON.stringify(requestDto));
|
||||
|
||||
|
||||
// Use the ENCRYPTED file for upload
|
||||
formData.append('file', {
|
||||
uri: `file://${encryptedFilePath}`,
|
||||
name: 'envelopeC_encrypted.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
} as any);
|
||||
|
||||
|
||||
console.log('📋 Request DTO:', JSON.stringify(requestDto, null, 2));
|
||||
console.log('📎 Encrypted File Path:', encryptedFilePath);
|
||||
|
||||
|
||||
// Step 6: Upload to server
|
||||
console.log('\n📝 STEP 6: Uploading encrypted file to server');
|
||||
console.log('───────────────────────────────────────────────────────');
|
||||
console.log('FormData prepared:', formData);
|
||||
|
||||
|
||||
const response = await axios.post(SUBMIT_ENVELOPE_C_UPLOAD, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authentication: `Bearer ${token.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
console.log('✅ UPLOAD SUCCESSFUL');
|
||||
console.log('Response:', response.data);
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
|
||||
|
||||
// Clean up temporary files
|
||||
await RNFS.unlink(plainFilePath).catch(e => console.log('Plain file cleanup:', e.message));
|
||||
await RNFS.unlink(encryptedFilePath).catch(e => console.log('Encrypted file cleanup:', e.message));
|
||||
|
||||
|
||||
Alert.alert('Success', 'Excel uploaded successfully');
|
||||
navigation.goBack();
|
||||
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('❌ Error saving/uploading Excel:', {
|
||||
message: e?.message,
|
||||
response: e?.response?.data,
|
||||
status: e?.response?.status,
|
||||
statusText: e?.response?.statusText,
|
||||
});
|
||||
|
||||
|
||||
Alert.alert('Error', e?.response?.data?.message || 'Save failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/* -------------------- UI -------------------- */
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.loader}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const qtyKey = findColumnKey(columns, ['qty', 'quantity']);
|
||||
const rateKey = findColumnKey(columns, ['rate', 'price']);
|
||||
const valueKey = findColumnKey(columns, ['value', 'amount', 'total']);
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView horizontal contentContainerStyle={{ padding: 10 }}>
|
||||
<View>
|
||||
{/* HEADER */}
|
||||
<View style={styles.row}>
|
||||
{columns.map(col => (
|
||||
<Text key={col} style={styles.headerCell}>{col}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
|
||||
{/* DATA */}
|
||||
<ScrollView style={{ maxHeight: 420 }}>
|
||||
{data.map((row, rowIndex) => (
|
||||
<View key={rowIndex} style={styles.row}>
|
||||
{columns.map(key => {
|
||||
const editable =
|
||||
!isTotalRow(row) && (key === qtyKey || key === rateKey);
|
||||
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={key}
|
||||
value={String(row[key])}
|
||||
editable={editable}
|
||||
keyboardType={editable ? 'numeric' : 'default'}
|
||||
style={[
|
||||
styles.cell,
|
||||
isTotalRow(row) && styles.totalCell,
|
||||
key === valueKey && styles.valueCell,
|
||||
]}
|
||||
onChangeText={text =>
|
||||
updateCell(rowIndex, key, text)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
|
||||
{tenderData?.buttonEnable && (
|
||||
<TouchableOpacity style={styles.button} onPress={saveUpdatedExcel}>
|
||||
<Text style={styles.buttonText}>Save Updated Excel</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={openEditor}
|
||||
activeOpacity={0.85}
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.primary }]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: `${colors.primary}18` }]}>
|
||||
<FileSpreadsheet size={22} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginHorizontal: spacing.s }}>
|
||||
<AppText variant="bodySmall" color={colors.text} style={{ fontWeight: '700' }}>
|
||||
Open Editor
|
||||
</AppText>
|
||||
<AppText variant="caption" color={colors.textSecondary}>
|
||||
Fill the editable cells, totals recalculate automatically. Formulas and locked cells are protected.
|
||||
</AppText>
|
||||
</View>
|
||||
<ChevronRight size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
padding: 14,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default EnvelopeCExcelPreview;
|
||||
|
||||
|
||||
/* -------------------- STYLES -------------------- */
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#fff' },
|
||||
loader: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
row: { flexDirection: 'row' },
|
||||
headerCell: {
|
||||
minWidth: 140,
|
||||
padding: 10,
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderWidth: 1,
|
||||
fontWeight: '700',
|
||||
},
|
||||
cell: {
|
||||
minWidth: 140,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#cbd5e1',
|
||||
},
|
||||
valueCell: {
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
totalCell: {
|
||||
backgroundColor: '#e5e7eb',
|
||||
fontWeight: '700',
|
||||
},
|
||||
button: {
|
||||
margin: 15,
|
||||
backgroundColor: colors.primary,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
@@ -11,10 +11,9 @@ import { BASE_URL } from '../../Services/ApiCall/ApiUrls';
|
||||
import Geolocation from 'react-native-geolocation-service';
|
||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||
import { Platform } from 'react-native';
|
||||
import { clampRGBA } from 'react-native-reanimated/lib/typescript/Colors';
|
||||
|
||||
const DeviceBinding = () => {
|
||||
const { colors, spacing, typography } = useTheme();
|
||||
const { colors, spacing } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { AlertCircle } from 'lucide-react-native';
|
||||
|
||||
const DscBinding = () => {
|
||||
const { colors, spacing, typography } = useTheme();
|
||||
const { colors, spacing } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const route = useRoute<any>();
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* EnvelopeCSpreadsheetScreen — full-screen native BOQ editor for Envelope-C.
|
||||
*
|
||||
* Opens the tender's BOQ template, renders the Excel-like editor (edit only the
|
||||
* originally-editable cells, live recalculation, validation), and submits the
|
||||
* metadata-preserving workbook through the existing Envelope-C API.
|
||||
*
|
||||
* Route params: { tender: <proceedToBid tender data>, buttonEnable?: boolean }.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, KeyboardAvoidingView, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { FileUp, Send } from 'lucide-react-native';
|
||||
|
||||
import InsideHeader from '../../../components/InsideHeader';
|
||||
import AppText from '../../../components/AppText';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import SpreadsheetView from '../../../Spreadsheet/components/SpreadsheetView';
|
||||
import useEnvelopeCWorkbook from './useEnvelopeCWorkbook';
|
||||
|
||||
const EnvelopeCSpreadsheetScreen: React.FC = () => {
|
||||
const route = useRoute<any>();
|
||||
const navigation = useNavigation<any>();
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
const tender = route.params?.tender;
|
||||
const buttonEnable = route.params?.buttonEnable ?? true;
|
||||
|
||||
const { ss, submitting, submit, importFromDevice } = useEnvelopeCWorkbook(tender, () =>
|
||||
navigation.goBack(),
|
||||
);
|
||||
|
||||
const canSubmit = ss.status === 'ready' && !submitting && ss.errorCount === 0;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<InsideHeader title="Envelope C" showArrow />
|
||||
|
||||
{/* Keep the grid + footer above the keyboard. Android already resizes the
|
||||
window (adjustResize); iOS needs padding behaviour. */}
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SpreadsheetView ss={ss} emptyHint="Loading the BOQ template…" />
|
||||
</View>
|
||||
|
||||
<View style={[styles.footer, { backgroundColor: colors.surface, borderTopColor: colors.border }]}>
|
||||
|
||||
|
||||
{buttonEnable && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.primaryBtn,
|
||||
{ backgroundColor: canSubmit ? colors.primary : colors.secondary, marginLeft: spacing.s },
|
||||
]}
|
||||
onPress={submit}
|
||||
disabled={!canSubmit}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.buttonText} />
|
||||
) : (
|
||||
<>
|
||||
<Send size={16} color={colors.buttonText} />
|
||||
<AppText variant="button" color={colors.buttonText}>
|
||||
{ss.errorCount > 0 ? `Fix ${ss.errorCount} error(s)` : 'Submit'}
|
||||
</AppText>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
secondaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
primaryBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default EnvelopeCSpreadsheetScreen;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* useEnvelopeCWorkbook — Envelope-C BOQ integration.
|
||||
*
|
||||
* Implements the mobile-bid encryption contract in MOBILE_BID_DSC_API_SPEC.md:
|
||||
* downloads the (plaintext) BOQ template into the editor, then on submit
|
||||
* 1. exports the metadata-preserving workbook bytes,
|
||||
* 2. AES/ECB encrypts the RAW file bytes with a SHA-1(K)-derived key,
|
||||
* 3. RSA-wraps the envelope key K with each officer's public key,
|
||||
* 4. uploads the raw ciphertext file + `randomKeyGenerated=K` + wrapped keys.
|
||||
*
|
||||
* Steps 2–3 are exactly what makes web rebid/opening able to RSA-unwrap the
|
||||
* key; without them the stored `contractor_enc_key` is a raw key and the DSC
|
||||
* unwrap times out.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import axios from 'axios';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import useSpreadsheet from '../../../Spreadsheet/hooks/useSpreadsheet';
|
||||
import FileManager from '../../../Spreadsheet/engine/FileManager';
|
||||
import {
|
||||
encrypt,
|
||||
encryptFileBytesToBase64,
|
||||
encryptWithRSA,
|
||||
sha256HexOfBytes,
|
||||
} from '../../../Services/Encryption/encrypt';
|
||||
import { GET_ENVELOPE_C, SUBMIT_ENVELOPE_C_UPLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
|
||||
interface OfficerKey {
|
||||
officersId: number;
|
||||
officerPublicKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
interface SaveEnvelopeOfficerDetail {
|
||||
officerId: number;
|
||||
officerEncryptedKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
const ENCRYPTED_FILE_NAME = 'envelopeC_encrypted.xlsx';
|
||||
|
||||
export const useEnvelopeCWorkbook = (tender: any, onSubmitted?: () => void) => {
|
||||
const ss = useSpreadsheet();
|
||||
const tokenObj = useSelector((state: any) => state.token?.token);
|
||||
const bearer = tokenObj?.token;
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const tenderId = tender?.tenderId;
|
||||
const encryptionKey: string = tender?.envelopeKeyDetailsResDTO?.envelopeCGenerateKey || '';
|
||||
|
||||
/* ------------------------------- load ------------------------------- */
|
||||
|
||||
const loadFromApi = useCallback(async () => {
|
||||
if (!tenderId) {
|
||||
Alert.alert('Error', 'Missing tender id.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${GET_ENVELOPE_C}${tenderId}`, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: { Authentication: `Bearer ${bearer}` },
|
||||
});
|
||||
await ss.loadFromBytes(response.data as ArrayBuffer, `envelopeC_${tenderId}.xlsx`);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Import failed', e?.response?.data?.message || e?.message || 'Could not load the BOQ template.');
|
||||
}
|
||||
// ss identity changes across renders; loadFromBytes is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenderId, bearer]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFromApi();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadFromApi]);
|
||||
|
||||
/* --------------------------- import from device --------------------- */
|
||||
|
||||
const importFromDevice = useCallback(async () => {
|
||||
try {
|
||||
const picked = await FileManager.pickWorkbook();
|
||||
if (!picked) return;
|
||||
await ss.loadFromBytes(picked.bytes, picked.name);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Import failed', e?.message || 'Could not open the selected file.');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ------------------------------- submit ----------------------------- */
|
||||
|
||||
const buildOfficerEncryptedKeys = useCallback((): SaveEnvelopeOfficerDetail[] | null => {
|
||||
const officerKeys: OfficerKey[] = tender?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
|
||||
// RSA/ECB/PKCS1-wrap the plaintext envelope key K with each officer's public
|
||||
// key (Base64 SPKI DER → ~344-char Base64 ciphertext), per
|
||||
// MOBILE_BID_DSC_API_SPEC.md. The web DSC applet unwraps it with the
|
||||
// officer's PRIVATE key during rebid/opening — a raw or empty value makes
|
||||
// the bid unrecoverable, so any wrap failure aborts the submission.
|
||||
// (`officersId` -> `officerId` per the API contract.)
|
||||
const wrapped: SaveEnvelopeOfficerDetail[] = [];
|
||||
for (const o of officerKeys) {
|
||||
const officerEncryptedKey = encryptWithRSA(encryptionKey, o.officerPublicKey);
|
||||
if (!officerEncryptedKey) return null;
|
||||
wrapped.push({
|
||||
officerId: o.officersId,
|
||||
officerEncryptedKey,
|
||||
officerType: o.officerType,
|
||||
});
|
||||
}
|
||||
return wrapped;
|
||||
}, [tender, encryptionKey]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
// 1) Validation gate — block submission while any editable cell is invalid.
|
||||
const errors = ss.validateAll();
|
||||
if (errors.length > 0) {
|
||||
Alert.alert('Validation failed', `Please fix ${errors.length} highlighted cell(s) before submitting.`);
|
||||
return;
|
||||
}
|
||||
if (!encryptionKey) {
|
||||
// Matches the server's HTTP 400 guard: a mobile bid must be client-
|
||||
// encrypted and echo randomKeyGenerated.
|
||||
Alert.alert('Error', 'Encryption key not available for this tender.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
let encryptedPath: string | null = null;
|
||||
try {
|
||||
// 2) RSA-wrap the envelope key K for every opening officer + contractor.
|
||||
const officerKeys = buildOfficerEncryptedKeys();
|
||||
if (!officerKeys) {
|
||||
throw new Error('Could not encrypt the envelope key for the opening officers.');
|
||||
}
|
||||
|
||||
// 3) Export the metadata-preserving workbook and AES-encrypt the RAW
|
||||
// bytes with the SHA-1(K) file key; upload as raw ciphertext bytes.
|
||||
const bytes = await ss.exportBytes();
|
||||
const cipherBase64 = encryptFileBytesToBase64(bytes, encryptionKey);
|
||||
if (!cipherBase64) throw new Error('Failed to encrypt the workbook.');
|
||||
|
||||
encryptedPath = await FileManager.writeBase64(ENCRYPTED_FILE_NAME, cipherBase64);
|
||||
const hashValue = sha256HexOfBytes(bytes);
|
||||
|
||||
// 4) Build the request exactly as MOBILE_BID_DSC_API_SPEC.md specifies.
|
||||
const requestDto = {
|
||||
tenderId,
|
||||
deptId: tender?.deptId,
|
||||
typesOfTender: tender?.typesOfTender,
|
||||
deviceType: 'Mobile',
|
||||
randomKeyGenerated: encryptionKey, // plaintext K, echoed back
|
||||
// The financial value lives inside the encrypted BOQ file; the text
|
||||
// fields still travel as AES(text) ciphertext per the contract.
|
||||
fieldValue: '',
|
||||
fieldValueInWord: encrypt('zero', encryptionKey) || '',
|
||||
hashValue,
|
||||
fileName: 'boq.xlsx',
|
||||
saveEnvelopesOfficersDetailsResDTO: officerKeys,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('requestDto', JSON.stringify(requestDto));
|
||||
formData.append('file', {
|
||||
uri: `file://${encryptedPath}`,
|
||||
name: 'boq.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
} as any);
|
||||
|
||||
// 5) Upload.
|
||||
await axios.post(SUBMIT_ENVELOPE_C_UPLOAD, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data', Authentication: `Bearer ${bearer}` },
|
||||
});
|
||||
|
||||
Alert.alert('Success', 'Envelope-C BOQ submitted successfully.');
|
||||
onSubmitted?.();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Save failed', e?.response?.data?.message || e?.message || 'Could not submit the workbook.');
|
||||
} finally {
|
||||
if (encryptedPath) await FileManager.remove(encryptedPath);
|
||||
setSubmitting(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ss, encryptionKey, tenderId, tender, bearer, buildOfficerEncryptedKeys, onSubmitted]);
|
||||
|
||||
return { ss, submitting, submit, importFromDevice, reload: loadFromApi };
|
||||
};
|
||||
|
||||
export default useEnvelopeCWorkbook;
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useCallback, useState, useEffect } from 'react';
|
||||
import { View, FlatList, ActivityIndicator, RefreshControl, Text, StyleSheet } from 'react-native';
|
||||
import Animated, { FadeInDown, FadeOutUp } from 'react-native-reanimated';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useFetchLiveAuction, useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import { useFetchLiveTender } from '../../Services/ApiCall/Hooks';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { togglePurchaseTender } from '../../Redux/Slices/SelectedPurchaseTender';
|
||||
import LiveTenderTile from './LiveTenderTile';
|
||||
@@ -35,6 +35,8 @@ const LiveTender = () => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// fetchNextPage identity changes each render; gating flags are the real deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const renderFooter = useCallback(() => {
|
||||
@@ -44,11 +46,13 @@ const LiveTender = () => {
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
// Theme colors object is recreated per render; footer only depends on the fetching flag.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFetchingNextPage]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: any) => {
|
||||
if (item.tenderStatus === "Cancelled") return null;
|
||||
const isCancelled = item.tenderStatus === "Cancelled";
|
||||
const isPurchase = SelectedPurchaseList.findIndex((prev: any) => prev.tenderId === item.tenderId)
|
||||
return <LiveTenderTile
|
||||
contractorId={user?.id}
|
||||
@@ -57,6 +61,7 @@ const LiveTender = () => {
|
||||
tenderId={item.tenderId}
|
||||
tenderDispatchDate={item.purchaseStart}
|
||||
isPurchased={isPurchase !== -1 ? true : false}
|
||||
isCancelled={isCancelled}
|
||||
purchaseEnd={item.purchaseEnd}
|
||||
tenderOfWork={item.departmentName}
|
||||
tenderFees={item.documentFees}
|
||||
@@ -65,12 +70,14 @@ const LiveTender = () => {
|
||||
onDetailsPress={() => navigation.navigate('LiveTenderDetail', { item })}
|
||||
/>
|
||||
},
|
||||
// Dispatch/navigation are stable; user?.id changes only with re-login.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[SelectedPurchaseList]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<Skelton />
|
||||
<Skeleton />
|
||||
</View>;
|
||||
}
|
||||
return (
|
||||
@@ -126,7 +133,7 @@ const LiveTender = () => {
|
||||
keyExtractor={(item) => String(item.tenderId)}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 50, paddingTop: 10 }}
|
||||
contentContainerStyle={{ paddingBottom: 120, paddingTop: 10 }}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.2}
|
||||
ListFooterComponent={renderFooter}
|
||||
|
||||
@@ -46,7 +46,7 @@ const Section = ({ title, icon: Icon, children, styles, colors }: any) => (
|
||||
|
||||
const LiveTenderDetail = () => {
|
||||
const route = useRoute<any>();
|
||||
const { colors, spacing, shadows, isDarkMode } = useTheme();
|
||||
const { colors, shadows } = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const tender = route?.params;
|
||||
const { data } = useFetchLiveTenderById(tender?.tenderId);
|
||||
@@ -206,7 +206,7 @@ const LiveTenderDetail = () => {
|
||||
const { dirs } = ReactNativeBlobUtil.fs;
|
||||
const path = `${dirs.DownloadDir}/${doc?.fileName}.pdf`;
|
||||
const downloadUrl = `${GET_DOCID}${doc?.docId}`;
|
||||
const response = await ReactNativeBlobUtil.config({
|
||||
await ReactNativeBlobUtil.config({
|
||||
fileCache: false,
|
||||
addAndroidDownloads: {
|
||||
useDownloadManager: true,
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import React, { memo } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import moment from 'moment';
|
||||
import { LiveTenderTileProps } from './types/types';
|
||||
import { Calendar, Tag, Info, ArrowRight, Clock, MapPin } from 'lucide-react-native';
|
||||
import { Calendar, Tag, ArrowRight, Clock } from 'lucide-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { format } from '../../Helper/DateFormat';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import AppText from '../../components/AppText';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
contractorId,
|
||||
data,
|
||||
isPurchased = false,
|
||||
isCancelled = false,
|
||||
onPurchasePress,
|
||||
tenderDispatchDate,
|
||||
tenderOfWork,
|
||||
@@ -24,7 +22,7 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
tenderNumber,
|
||||
}) => {
|
||||
const navigation: any = useNavigation();
|
||||
const { colors, shadows, spacing } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const purchaseEnded = moment().isAfter(moment(purchaseEnd, 'YYYY-MM-DD HH:mm:ss'));
|
||||
|
||||
@@ -36,23 +34,16 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
|
||||
const purchased = contractorIds.includes(String(contractorId));
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isPurchased) return colors.error;
|
||||
if (purchaseEnded) return colors.textSecondary;
|
||||
if (purchased) return colors.textSecondary;
|
||||
return colors.success;
|
||||
};
|
||||
|
||||
const statusColor = getStatusColor();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
activeOpacity={isCancelled ? 1 : 0.9}
|
||||
disabled={isCancelled}
|
||||
onPress={() => navigation.navigate('LiveTenderDetail', data)}
|
||||
style={[styles.container, {
|
||||
backgroundColor: colors.surface,
|
||||
shadowColor: colors.shadow,
|
||||
borderColor: colors.border
|
||||
borderColor: isCancelled ? colors.error : colors.border,
|
||||
opacity: isCancelled ? 0.6 : 1,
|
||||
}]}
|
||||
>
|
||||
|
||||
@@ -66,9 +57,17 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
</AppText>
|
||||
</View>
|
||||
|
||||
<AppText variant="body" style={{ color: colors.primary }}>
|
||||
₹{tenderFees}
|
||||
</AppText>
|
||||
{isCancelled ? (
|
||||
<View style={[styles.statusBadge, { backgroundColor: colors.error }]}>
|
||||
<AppText variant="caption" style={{ color: '#fff', fontWeight: '700' }}>
|
||||
Cancelled
|
||||
</AppText>
|
||||
</View>
|
||||
) : (
|
||||
<AppText variant="body" style={{ color: colors.primary }}>
|
||||
₹{tenderFees}
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Title Section */}
|
||||
@@ -113,17 +112,19 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
|
||||
{/* Actions Section */}
|
||||
<View style={styles.actionRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.detailsBtn, { borderColor: colors.border }]}
|
||||
onPress={() => navigation.navigate('LiveTenderDetail', data)}
|
||||
>
|
||||
<AppText variant="bodySmall" style={{ color: colors.textSecondary, fontWeight: '600' }}>
|
||||
View Details
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
{!isCancelled && (
|
||||
<TouchableOpacity
|
||||
style={[styles.detailsBtn, { borderColor: colors.border }]}
|
||||
onPress={() => navigation.navigate('LiveTenderDetail', data)}
|
||||
>
|
||||
<AppText variant="bodySmall" style={{ color: colors.textSecondary, fontWeight: '600' }}>
|
||||
View Details
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
disabled={purchaseEnded || purchased}
|
||||
disabled={isCancelled || purchaseEnded || purchased}
|
||||
onPress={isPurchased ? () => navigation.navigate(NavigationString.PurchaseTenderList) : onPurchasePress}
|
||||
style={styles.actionBtnWrapper}
|
||||
>
|
||||
@@ -131,7 +132,7 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
style={[
|
||||
styles.actionBtn,
|
||||
{
|
||||
backgroundColor: (purchaseEnded || purchased)
|
||||
backgroundColor: (isCancelled || purchaseEnded || purchased)
|
||||
? colors.textSecondary
|
||||
: isPurchased
|
||||
? colors.success // Green for Go to Cart
|
||||
@@ -140,9 +141,9 @@ const LiveTenderTile: React.FC<LiveTenderTileProps> = ({
|
||||
]}
|
||||
>
|
||||
<AppText variant="button" style={{ color: '#fff', fontSize: 12 }}>
|
||||
{purchaseEnded ? (purchased ? 'Purchased' : 'Closed') : (purchased ? 'Purchased' : isPurchased ? 'GO TO CART' : 'Add to Cart')}
|
||||
{isCancelled ? 'Cancelled' : purchaseEnded ? (purchased ? 'Purchased' : 'Closed') : (purchased ? 'Purchased' : isPurchased ? 'GO TO CART' : 'Add to Cart')}
|
||||
</AppText>
|
||||
{(!purchaseEnded && !purchased) && <ArrowRight size={14} color="#fff" />}
|
||||
{(!isCancelled && !purchaseEnded && !purchased) && <ArrowRight size={14} color="#fff" />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -187,6 +188,11 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 16,
|
||||
fontWeight: '700',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Eye,
|
||||
Layers,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
} from 'lucide-react-native';
|
||||
import colors from '../../../Constant/Color';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { StyleSheet, View, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator } from 'react-native'
|
||||
import React, { useCallback } from 'react'
|
||||
import { InsideHeader } from '../../Component/Index'
|
||||
import { InsideHeader } from '../../components'
|
||||
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native'
|
||||
import { useFetchTenderById, useProceedToBid } from '../../Services/ApiCall/Hooks'
|
||||
import { useTheme } from '../../Hooks/useTheme'
|
||||
import AppText from '../../components/AppText'
|
||||
import { CircleDot, Clock, CheckCircle, XCircle, Calendar, FileText, Shield, AlertCircle } from 'lucide-react-native'
|
||||
import { CircleDot, CheckCircle, XCircle, Calendar, Shield, AlertCircle } from 'lucide-react-native'
|
||||
import Animated, { FadeInUp, FadeInDown, FadeIn, SlideInRight, Layout } from 'react-native-reanimated'
|
||||
import { apiCall } from '../../Axios/Axios'
|
||||
import NavigationString from '../../Constant/NavigationString'
|
||||
@@ -21,7 +21,7 @@ const ProceedToBidDetail = () => {
|
||||
const { colors, spacing, shadows, typography } = useTheme();
|
||||
const route = useRoute<any>();
|
||||
const tenderDataId = route.params?.tender;
|
||||
const { data, refetch, isRefetching } = useProceedToBid(tenderDataId?.tenderId);
|
||||
const { refetch, isRefetching } = useProceedToBid(tenderDataId?.tenderId);
|
||||
const { data: tenderResponse, refetch: tenderRefetch }: any = useFetchTenderById(tenderDataId?.tenderId);
|
||||
const tenderData = tenderResponse?.data;
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
@@ -50,7 +50,7 @@ const ProceedToBidDetail = () => {
|
||||
navigation.navigate(NavigationString.DscBinding, {
|
||||
message: "Your DSC has been expired. Please map new DSC from the web screen"
|
||||
});
|
||||
} else if (deviceBinding && deviceVerification === 'PENDING') {
|
||||
} else if (deviceBinding && deviceVerification !== 'PENDING') {
|
||||
navigation.navigate(NavigationString.DeviceBinding, {
|
||||
message: "Please approve this device to enable bidding access from the web.",
|
||||
showBindButton: false
|
||||
@@ -128,6 +128,8 @@ const ProceedToBidDetail = () => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
tenderRefetch();
|
||||
// Refetch keyed by tender id only; tenderRefetch identity changes each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenderData?.tenderId])
|
||||
);
|
||||
|
||||
@@ -374,7 +376,7 @@ const ProceedToBidDetail = () => {
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={[styles.footer, { borderTopColor: colors.border }]}
|
||||
>
|
||||
{tenderData?.proceedtoBid && !tenderData?.viewBid && tenderData?.biddingStatus && tenderData?.bidWithdrawStatus === false ? (
|
||||
{tenderData?.proceedtoBid && tenderData?.biddingStatus && tenderData?.bidWithdrawStatus === false ? (
|
||||
<TouchableOpacity style={styles.proceedButton} onPress={handleStartBidding} disabled={isRefetching}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.buttonText} />
|
||||
@@ -406,7 +408,7 @@ const ProceedToBidDetail = () => {
|
||||
|
||||
export default ProceedToBidDetail
|
||||
|
||||
const makeStyles = (colors: any, spacing: any, shadows: any) => StyleSheet.create({
|
||||
const makeStyles = (colors: any, spacing: any, _shadows: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: spacing.m,
|
||||
|
||||
@@ -8,18 +8,14 @@ import {
|
||||
} from 'react-native';
|
||||
import Animated, { FadeInDown, FadeIn } from 'react-native-reanimated';
|
||||
import {
|
||||
CreditCard,
|
||||
Calendar,
|
||||
Hash,
|
||||
IndianRupee,
|
||||
Percent,
|
||||
Server,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Receipt,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react-native';
|
||||
import { InsideHeader, Loader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useOfflineTenderPurchase } from '../../Services/ApiCall/Hooks';
|
||||
import { StackActions, useNavigation } from '@react-navigation/native';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState, useCallback, memo, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, ActivityIndicator } from 'react-native';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, FlatList, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import moment from 'moment';
|
||||
import { useFetchPurchaseTender } from '../../Services/ApiCall/Hooks';
|
||||
import PurchaseTenderTile from './PurchaseTenderTile';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setTender } from '../../Redux/Slices/SelectedTender';
|
||||
import { SearchInput } from '../../Component/Serach/SeacrhInput';
|
||||
import { Skelton } from '../../Component/Index';
|
||||
import { SearchInput } from '../../components/SearchInput';
|
||||
import { Skeleton } from '../../components';
|
||||
import { allBooleanProps } from './types/types';
|
||||
import FilterModal from './Modal/FilterModal';
|
||||
import colors from '../../Constant/Color';
|
||||
@@ -59,6 +59,8 @@ const PurchaseTenderAllList = () => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
// fetchNextPage identity changes each render; gating flags are the real deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasNextPage, isFetchingNextPage]);
|
||||
|
||||
|
||||
@@ -111,13 +113,13 @@ const PurchaseTenderAllList = () => {
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
{isLoading ? <Skelton />
|
||||
{isLoading ? <Skeleton />
|
||||
: <FlatList
|
||||
data={data?.result ?? []}
|
||||
keyExtractor={(item) => String(item.tenderId || item.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 200, paddingHorizontal: 10 }}
|
||||
renderItem={({ item, index }) => (
|
||||
renderItem={({ item }) => (
|
||||
<PurchaseTenderTile
|
||||
item={item}
|
||||
onPressDetails={() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -16,7 +15,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { ChevronDown, ChevronRight, Trash2, ShoppingBag, FileText, Tag } from 'lucide-react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import NavigationString from '../../Constant/NavigationString';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -27,11 +26,9 @@ import { useEffect } from 'react';
|
||||
|
||||
const PurchaseTenderList = () => {
|
||||
const Navigation: any = useNavigation();
|
||||
const Dispatch = useDispatch();
|
||||
const { colors, spacing, shadows, typography } = useTheme();
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const SelectedPurchaseList = useSelector((state: any) => state.selectPurchaseTender);
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const toggleExpand = (index: number) => {
|
||||
setExpandedIndex(prev => (prev === index ? null : index));
|
||||
@@ -41,6 +38,8 @@ const PurchaseTenderList = () => {
|
||||
if (SelectedPurchaseList.length === 0) {
|
||||
Navigation.goBack();
|
||||
}
|
||||
// Navigation identity is stable; the list is the real dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [SelectedPurchaseList]);
|
||||
|
||||
const styles = makeStyles(colors, spacing, shadows);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Animated, { FadeInUp, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import moment from 'moment';
|
||||
import CountdownTimer from '../../Component/Counter/CountdownTimer';
|
||||
import CountdownTimer from '../../components/CountdownTimer';
|
||||
import { useFetchTenderById } from '../../Services/ApiCall/Hooks';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import TenderFullDetail from './Modal/TenderFullDetail';
|
||||
import AppText from '../../components/AppText';
|
||||
import { useTheme } from '../../Hooks/useTheme';
|
||||
import { Calendar, Wallet, Banknote, ArrowRight, CircleDot, Clock } from 'lucide-react-native';
|
||||
import { Calendar, Banknote, ArrowRight, CircleDot, Clock } from 'lucide-react-native';
|
||||
|
||||
const PurchaseTenderTile = ({ item, onPressDetails }: any) => {
|
||||
const { colors, spacing, shadows } = useTheme();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { InsideHeader } from '../../Component/Index';
|
||||
import { InsideHeader } from '../../components';
|
||||
import {
|
||||
useFetchLiveTender,
|
||||
useFetchPurchaseTender,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
import { buildEnvelopePayload, buildQcbsPayload, findEnvelopeFile, validateEnvelope, validateQcbs } from '../envelopePayload';
|
||||
import { convertDigitsToWords } from '../envelopeCrypto';
|
||||
|
||||
jest.mock('../../../../Services/Encryption/encrypt', () => ({
|
||||
encrypt: jest.fn((value: string) => `enc(${value})`),
|
||||
encryptWithRSA: jest.fn(() => 'rsa-wrapped-key'),
|
||||
}));
|
||||
|
||||
const tenderData = {
|
||||
deptId: 7,
|
||||
tenderId: 42,
|
||||
tenderEnvelopeAList: [
|
||||
{
|
||||
tenderEnvelopeASequnceId: 1,
|
||||
labelNameA: 'Bid Amount',
|
||||
labelTypeA: '1',
|
||||
labelMandatoryA: true,
|
||||
tenderEnvelopeANameId: 11,
|
||||
envelopeATemplateName: 'TemplateA',
|
||||
},
|
||||
{
|
||||
tenderEnvelopeASequnceId: 2,
|
||||
labelNameA: 'Supporting Doc',
|
||||
labelTypeA: '3',
|
||||
labelMandatoryA: false,
|
||||
},
|
||||
],
|
||||
envelopeKeyDetailsResDTO: {
|
||||
envelopeAGenerateKey: 'aes-key-A',
|
||||
envelopeQcbsAndLcs: 'aes-key-qcbs',
|
||||
departmentOfficerPublicKeys: [
|
||||
{ officersId: 5, officerPublicKey: 'pub-key', officerType: 'AO' },
|
||||
],
|
||||
},
|
||||
tenderEnvelopeQcbsList: {
|
||||
templateId: 3,
|
||||
qcbsTemplateName: 'QCBS-T',
|
||||
questions: [
|
||||
{ questionId: 100, question: 'Q1?', parameters: [{ option: 'Yes', mark: 5, strUser: '1' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('convertDigitsToWords', () => {
|
||||
it('spells out each digit', () => {
|
||||
expect(convertDigitsToWords('120')).toBe('One Two Zero');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEnvelope', () => {
|
||||
it('fails when a mandatory field is empty', () => {
|
||||
expect(validateEnvelope(tenderData, 'A', {})).toBe(false);
|
||||
expect(validateEnvelope(tenderData, 'A', { 1: ' ' })).toBe(false);
|
||||
});
|
||||
|
||||
it('passes when mandatory fields are filled (optional file may be empty)', () => {
|
||||
expect(validateEnvelope(tenderData, 'A', { 1: '5000' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEnvelopeFile', () => {
|
||||
it('returns the picked file for the file field', () => {
|
||||
const file = { uri: 'file://x.pdf', name: 'x.pdf', type: 'application/pdf' };
|
||||
expect(findEnvelopeFile(tenderData, 'A', { 2: file })).toBe(file);
|
||||
expect(findEnvelopeFile(tenderData, 'A', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnvelopePayload', () => {
|
||||
it('returns null when the tender has no encryption key', () => {
|
||||
expect(buildEnvelopePayload({ envelopeKeyDetailsResDTO: {} }, 'A', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('encrypts non-file fields in figures and words, echoing the key per backend contract', () => {
|
||||
const payload = buildEnvelopePayload(tenderData, 'A', { 1: '50' });
|
||||
|
||||
expect(payload.deptId).toBe(7);
|
||||
expect(payload.tenderId).toBe(42);
|
||||
expect(payload.tenderEnvelopeANameId).toBe(11);
|
||||
expect(payload.envelopeATemplateName).toBe('TemplateA');
|
||||
expect(payload.deviceType).toBe('Mobile');
|
||||
expect(payload.randomKeyGenerated).toBe('aes-key-A');
|
||||
|
||||
expect(payload.saveAData).toHaveLength(1); // file fields excluded
|
||||
expect(payload.saveAData[0].fieldValue).toBe('enc(50)');
|
||||
expect(payload.saveAData[0].fieldValueInWord).toBe('enc(Five Zero)');
|
||||
|
||||
expect(payload.saveEnvelopesOfficersDetailsResDTO).toEqual([
|
||||
{ officerId: 5, officerEncryptedKey: 'rsa-wrapped-key', officerType: 'AO' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filesOnly variant carries only file fields, without values', () => {
|
||||
const payload = buildEnvelopePayload(tenderData, 'A', { 1: '50' }, true);
|
||||
expect(payload.saveAData).toHaveLength(1);
|
||||
expect(payload.saveAData[0].labelTypeA).toBe('3');
|
||||
expect(payload.saveAData[0].fieldValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('QCBS', () => {
|
||||
it('validates that at least one option is selected', () => {
|
||||
expect(validateQcbs(tenderData, {})).toBe(false);
|
||||
expect(validateQcbs(tenderData, { 100: { option: 'Yes' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('builds a payload containing only the selected, encrypted option', () => {
|
||||
const payload = buildQcbsPayload(tenderData, { 100: { option: 'Yes', mark: 5, strUser: '1' } });
|
||||
expect(payload.templateId).toBe(3);
|
||||
expect(payload.questions).toHaveLength(1);
|
||||
expect(payload.questions[0].parameters).toEqual([
|
||||
{ option: 'enc(Yes)', mark: 5, strUser: 'enc(1)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits an empty parameters array for unanswered questions', () => {
|
||||
const payload = buildQcbsPayload(tenderData, {});
|
||||
expect(payload.questions[0].parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
import AppText from '../../../../components/AppText';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const EnvelopeButton = ({ title, isActive, onPress }: Props) => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.button,
|
||||
{ borderColor: colors.border, backgroundColor: colors.surface },
|
||||
isActive && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={isActive ? colors.buttonText : colors.primary}
|
||||
style={styles.title as any}
|
||||
>
|
||||
{title}
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1.5,
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: { fontWeight: '700' } as any,
|
||||
});
|
||||
|
||||
export default memo(EnvelopeButton);
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { FileUp } from 'lucide-react-native';
|
||||
import MyInput from '../../../../components/MyInput';
|
||||
import AppText from '../../../../components/AppText';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
import type { EnvelopeType } from '../envelopeTypes';
|
||||
|
||||
type Props = {
|
||||
item: any;
|
||||
envelopeType: EnvelopeType;
|
||||
value: any;
|
||||
/** False once the envelope has already been submitted. */
|
||||
showInput: boolean;
|
||||
/** Whole-envelope validation state — drives the mandatory-asterisk color. */
|
||||
envelopeValid: boolean;
|
||||
onChangeText: (text: string) => void;
|
||||
onPickFile: () => void;
|
||||
};
|
||||
|
||||
const keyboardTypeFor = (labelType: string) =>
|
||||
labelType === '1' || labelType === '4' ? 'numeric' : 'default';
|
||||
|
||||
const placeholderFor = (labelType: string, labelName: string) => {
|
||||
if (labelType === '1') { return `Enter ${labelName} numeric value`; }
|
||||
if (labelType === '4') { return `Enter ${labelName} currency value`; }
|
||||
return `Enter ${labelName} Alph-numeric value`;
|
||||
};
|
||||
|
||||
/** One envelope form field: numeric/text/currency input or a PDF picker. */
|
||||
const EnvelopeFieldItem = ({
|
||||
item,
|
||||
envelopeType,
|
||||
value,
|
||||
showInput,
|
||||
envelopeValid,
|
||||
onChangeText,
|
||||
onPickFile,
|
||||
}: Props) => {
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
const sequenceId = item?.[`tenderEnvelope${envelopeType}SequnceId`];
|
||||
if (!sequenceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelName = item?.[`labelName${envelopeType}`] || '';
|
||||
const labelType = item?.[`labelType${envelopeType}`] || '';
|
||||
const isMandatory = item?.[`labelMandatory${envelopeType}`];
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInUp.delay(80).springify()}>
|
||||
<View
|
||||
style={[
|
||||
styles.envelopeItem,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderLeftColor: colors.primary,
|
||||
padding: spacing.m,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Label */}
|
||||
<View style={styles.labelRow}>
|
||||
<AppText variant="caption" color={colors.textSecondary} style={styles.labelText as any}>
|
||||
{labelName || '-'}
|
||||
</AppText>
|
||||
{isMandatory && (
|
||||
<AppText variant="caption" color={envelopeValid ? colors.textSecondary : colors.error}>
|
||||
*
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Input directly below the label, inside the same card */}
|
||||
{showInput ? (
|
||||
<View style={{ marginTop: spacing.s }}>
|
||||
{labelType === '3' ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.uploadButton,
|
||||
{ borderColor: colors.primary, backgroundColor: colors.surface, marginTop: spacing.s },
|
||||
]}
|
||||
onPress={onPickFile}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FileUp size={16} color={colors.primary} />
|
||||
<AppText variant="bodySmall" color={colors.primary} style={styles.uploadText as any}>
|
||||
{value ? 'Change Document' : 'Upload Document'}
|
||||
</AppText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{value && (
|
||||
<AppText variant="caption" color={colors.success} style={styles.fileName as any}>
|
||||
📄 {(value as any)?.name}
|
||||
</AppText>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<MyInput
|
||||
placeholder={placeholderFor(labelType, labelName)}
|
||||
value={value ?? ''}
|
||||
onChangeText={onChangeText}
|
||||
keyboardType={keyboardTypeFor(labelType)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<AppText variant="caption" color={colors.textSecondary} style={{ marginTop: spacing.xs }}>
|
||||
Envelope {envelopeType} unavailable
|
||||
</AppText>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
envelopeItem: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 3,
|
||||
},
|
||||
labelRow: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
labelText: { fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.3 } as any,
|
||||
uploadButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
uploadText: { fontWeight: '600' } as any,
|
||||
fileName: { marginTop: 4 } as any,
|
||||
});
|
||||
|
||||
export default memo(EnvelopeFieldItem);
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { FadeInUp, SlideInRight } from 'react-native-reanimated';
|
||||
import AppText from '../../../../components/AppText';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
|
||||
type Props = {
|
||||
question: any;
|
||||
selectedOption: any;
|
||||
onSelect: (option: any) => void;
|
||||
};
|
||||
|
||||
/** One LCS/QCBS question with its single-select option list. */
|
||||
const QcbsQuestionCard = ({ question, selectedOption, onSelect }: Props) => {
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.questionCard,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderLeftColor: colors.primary,
|
||||
padding: spacing.m,
|
||||
marginBottom: spacing.m,
|
||||
},
|
||||
]}
|
||||
entering={FadeInUp.delay(100).springify()}
|
||||
>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={colors.text}
|
||||
style={[styles.questionText as any, { marginBottom: spacing.s }]}
|
||||
>
|
||||
{question.question}
|
||||
</AppText>
|
||||
|
||||
{question.parameters?.map((param: any, index: number) => {
|
||||
const isSelected = selectedOption?.option === param.option;
|
||||
|
||||
return (
|
||||
<Animated.View key={index} entering={SlideInRight.delay(index * 80).springify()}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionContainer,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.m,
|
||||
marginBottom: spacing.s,
|
||||
},
|
||||
isSelected && {
|
||||
borderColor: colors.primary,
|
||||
borderWidth: 2,
|
||||
backgroundColor: `${colors.primary}08`,
|
||||
},
|
||||
]}
|
||||
onPress={() => onSelect(param)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.optionRow}>
|
||||
<View style={[styles.radioButton, { borderColor: isSelected ? colors.primary : colors.border }]}>
|
||||
{isSelected && <View style={[styles.radioButtonInner, { backgroundColor: colors.primary }]} />}
|
||||
</View>
|
||||
<View style={styles.flex1}>
|
||||
<AppText
|
||||
variant="bodySmall"
|
||||
color={isSelected ? colors.primary : colors.text}
|
||||
style={{ fontWeight: isSelected ? '700' : '500' }}
|
||||
>
|
||||
{param.option}
|
||||
</AppText>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex1: { flex: 1 },
|
||||
questionCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderLeftWidth: 3,
|
||||
},
|
||||
questionText: { fontWeight: '700', lineHeight: 22 } as any,
|
||||
optionContainer: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
radioButton: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
radioButtonInner: {
|
||||
width: 11,
|
||||
height: 11,
|
||||
borderRadius: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export default memo(QcbsQuestionCard);
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { CheckSquare, Square } from 'lucide-react-native';
|
||||
import AppText from '../../../../components/AppText';
|
||||
import { useTheme } from '../../../../Hooks/useTheme';
|
||||
|
||||
type Props = {
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const TermsRow = ({ checked, onToggle }: Props) => {
|
||||
const { colors, spacing } = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.termsRow,
|
||||
{ borderTopColor: colors.border, marginTop: spacing.m, paddingTop: spacing.m },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={onToggle}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked }}
|
||||
accessibilityLabel="Accept terms and conditions"
|
||||
>
|
||||
{checked ? (
|
||||
<CheckSquare size={22} color={colors.primary} strokeWidth={2.5} />
|
||||
) : (
|
||||
<Square size={22} color={colors.textSecondary} strokeWidth={2} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<AppText variant="bodySmall" color={colors.text} style={styles.termsText as any}>
|
||||
Accept Terms & Conditions
|
||||
</AppText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
termsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
termsText: { fontWeight: '600', flex: 1 } as any,
|
||||
});
|
||||
|
||||
export default memo(TermsRow);
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Sealed-bid crypto helpers for tender envelopes.
|
||||
*
|
||||
* SECURITY: nothing in this module may ever log key material, plaintext bid
|
||||
* values, or ciphertext — the previous implementation dumped all three to
|
||||
* logcat and that was a reportable incident, not a debugging aid.
|
||||
*/
|
||||
import { encryptWithRSA } from '../../../Services/Encryption/encrypt';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import type { OfficerKey, SaveEnvelopeOfficerDetail } from './envelopeTypes';
|
||||
|
||||
const DIGIT_WORDS: Record<string, string> = {
|
||||
'0': 'Zero',
|
||||
'1': 'One',
|
||||
'2': 'Two',
|
||||
'3': 'Three',
|
||||
'4': 'Four',
|
||||
'5': 'Five',
|
||||
'6': 'Six',
|
||||
'7': 'Seven',
|
||||
'8': 'Eight',
|
||||
'9': 'Nine',
|
||||
};
|
||||
|
||||
/** "120" → "One Two Zero" — the backend stores bid figures in words too. */
|
||||
export const convertDigitsToWords = (value: string): string =>
|
||||
value
|
||||
.split('')
|
||||
.map(digit => DIGIT_WORDS[digit] || '')
|
||||
.join(' ') || value;
|
||||
|
||||
/**
|
||||
* Wraps the symmetric envelope key with each department officer's RSA public
|
||||
* key. A failed wrap is recorded as an empty key (matching the backend
|
||||
* contract) and logged WITHOUT any key material.
|
||||
*/
|
||||
export const buildOfficerEncryptedKeys = (
|
||||
envelopeKey: string,
|
||||
officerKeys: OfficerKey[],
|
||||
): SaveEnvelopeOfficerDetail[] => {
|
||||
// Counts only — never log the key being wrapped or the resulting ciphertext.
|
||||
logger.debug('envelope', `[crypto] wrapping envelope key for ${officerKeys.length} officer(s)`);
|
||||
|
||||
let wrapped = 0;
|
||||
const result = officerKeys.map(officer => {
|
||||
try {
|
||||
const officerEncryptedKey = encryptWithRSA(envelopeKey, officer.officerPublicKey) || '';
|
||||
if (!officerEncryptedKey) {
|
||||
logger.warn('envelope', `RSA wrap produced empty key for officer ${officer.officersId}`);
|
||||
} else {
|
||||
wrapped += 1;
|
||||
}
|
||||
return {
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey,
|
||||
officerType: officer.officerType,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('envelope', `RSA wrap failed for officer ${officer.officersId}`, {
|
||||
error: String(error),
|
||||
});
|
||||
return {
|
||||
officerId: officer.officersId,
|
||||
officerEncryptedKey: '',
|
||||
officerType: officer.officerType,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
'envelope',
|
||||
`[crypto] officer key wrap complete: ${wrapped}/${officerKeys.length} succeeded`,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Envelope payload construction and validation — pure functions over the
|
||||
* tender data and form state. No React, no HTTP, no UI, no key logging.
|
||||
*/
|
||||
import { encrypt } from '../../../Services/Encryption/encrypt';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import { buildOfficerEncryptedKeys, convertDigitsToWords } from './envelopeCrypto';
|
||||
import type { EnvelopeType, PickedFile } from './envelopeTypes';
|
||||
|
||||
/** Every mandatory field of the active envelope must be filled. */
|
||||
export const validateEnvelope = (
|
||||
tenderData: any,
|
||||
envelopeType: EnvelopeType,
|
||||
formValues: Record<number, any>,
|
||||
): boolean => {
|
||||
const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
|
||||
|
||||
let missingSeqId: number | null = null;
|
||||
const missing = list.some((item: any) => {
|
||||
const seqId = item[`tenderEnvelope${envelopeType}SequnceId`];
|
||||
const isMandatory = item[`labelMandatory${envelopeType}`];
|
||||
const value = formValues?.[seqId];
|
||||
|
||||
if (!isMandatory) { return false; }
|
||||
const empty = item[`labelType${envelopeType}`] === '3'
|
||||
? !value
|
||||
: !value || value.toString().trim() === '';
|
||||
if (empty) { missingSeqId = seqId; }
|
||||
return empty;
|
||||
});
|
||||
|
||||
logger.debug('envelope', `[validate] envelope ${envelopeType}`, {
|
||||
fields: list.length,
|
||||
valid: !missing,
|
||||
...(missing ? { firstMissingSeqId: missingSeqId } : {}),
|
||||
});
|
||||
|
||||
return !missing;
|
||||
};
|
||||
|
||||
/** LCS/QCBS: at least one question must have a selected option. */
|
||||
export const validateQcbs = (
|
||||
tenderData: any,
|
||||
selectedOptions: Record<number, any>,
|
||||
): boolean => {
|
||||
const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
|
||||
const answered = questions.filter((q: any) => selectedOptions[q.questionId]).length;
|
||||
logger.debug('envelope', '[validate] LCS/QCBS', {
|
||||
questions: questions.length,
|
||||
answered,
|
||||
valid: answered > 0,
|
||||
});
|
||||
return answered > 0;
|
||||
};
|
||||
|
||||
/** Returns the picked file for the envelope's file field, if any. */
|
||||
export const findEnvelopeFile = (
|
||||
tenderData: any,
|
||||
envelopeType: EnvelopeType,
|
||||
formValues: Record<number, any>,
|
||||
): PickedFile | null => {
|
||||
const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
|
||||
const fileField = list.find((item: any) => item[`labelType${envelopeType}`] === '3');
|
||||
const seqId = fileField ? fileField[`tenderEnvelope${envelopeType}SequnceId`] : null;
|
||||
const file = (seqId && formValues?.[seqId]) || null;
|
||||
logger.debug('envelope', `[file] locate document field for envelope ${envelopeType}`, {
|
||||
hasFileField: !!fileField,
|
||||
seqId,
|
||||
fileSelected: !!file,
|
||||
fileName: file?.name,
|
||||
fileType: file?.type,
|
||||
});
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the save-envelope payload with every field value AES-encrypted under
|
||||
* the envelope key and the key RSA-wrapped per officer.
|
||||
*
|
||||
* @param filesOnly when true, builds the variant used as the multipart
|
||||
* `requestDto` for the file upload (file fields only); otherwise the
|
||||
* data-submission variant (non-file fields only).
|
||||
* @returns the payload, or null when the tender carries no encryption key.
|
||||
*/
|
||||
export const buildEnvelopePayload = (
|
||||
tenderData: any,
|
||||
envelopeType: EnvelopeType,
|
||||
formValues: Record<number, any>,
|
||||
filesOnly: boolean = false,
|
||||
): any | null => {
|
||||
const list = tenderData?.[`tenderEnvelope${envelopeType}List`] || [];
|
||||
const encryptionKey =
|
||||
tenderData?.envelopeKeyDetailsResDTO?.[`envelope${envelopeType}GenerateKey`] || '';
|
||||
|
||||
logger.info('envelope', `[payload] building envelope ${envelopeType} payload`, {
|
||||
filesOnly,
|
||||
totalFields: list.length,
|
||||
hasEncryptionKey: !!encryptionKey,
|
||||
});
|
||||
|
||||
if (!encryptionKey) {
|
||||
logger.error('envelope', `no encryption key available for envelope ${envelopeType}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const combinedData = list
|
||||
.filter((item: any) => {
|
||||
const labelType = item[`labelType${envelopeType}`];
|
||||
return filesOnly ? labelType === '3' : labelType !== '3';
|
||||
})
|
||||
.map((item: any) => {
|
||||
const seqId = item[`tenderEnvelope${envelopeType}SequnceId`];
|
||||
const labelType = item[`labelType${envelopeType}`];
|
||||
const labelName = item[`labelName${envelopeType}`];
|
||||
const isMandatory = item[`labelMandatory${envelopeType}`];
|
||||
const rawValue = formValues[seqId];
|
||||
|
||||
if (labelType === '3') {
|
||||
return {
|
||||
[`tenderEnvelope${envelopeType}SequnceId`]: seqId,
|
||||
[`labelName${envelopeType}`]: labelName,
|
||||
[`labelType${envelopeType}`]: labelType,
|
||||
[`labelMandatory${envelopeType}`]: isMandatory,
|
||||
};
|
||||
}
|
||||
|
||||
const encryptedValue = rawValue ? encrypt(String(rawValue), encryptionKey) : '';
|
||||
const valueInWords = rawValue ? convertDigitsToWords(String(rawValue)) : '';
|
||||
const encryptedValueInWord = valueInWords ? encrypt(valueInWords, encryptionKey) : '';
|
||||
|
||||
return {
|
||||
[`tenderEnvelope${envelopeType}SequnceId`]: seqId,
|
||||
[`labelName${envelopeType}`]: labelName,
|
||||
[`labelType${envelopeType}`]: labelType,
|
||||
[`labelMandatory${envelopeType}`]: isMandatory,
|
||||
fieldValue: encryptedValue,
|
||||
fieldValueInWord: encryptedValueInWord,
|
||||
};
|
||||
});
|
||||
|
||||
logger.debug('envelope', `[payload] envelope ${envelopeType} fields encrypted`, {
|
||||
filesOnly,
|
||||
encryptedFields: combinedData.length,
|
||||
});
|
||||
|
||||
const officerEncryptedKeys = buildOfficerEncryptedKeys(
|
||||
encryptionKey,
|
||||
tenderData?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [],
|
||||
);
|
||||
|
||||
logger.info('envelope', `[payload] envelope ${envelopeType} payload ready`, {
|
||||
filesOnly,
|
||||
savedFields: combinedData.length,
|
||||
officers: officerEncryptedKeys.length,
|
||||
});
|
||||
|
||||
return {
|
||||
deptId: tenderData?.deptId,
|
||||
tenderId: tenderData?.tenderId,
|
||||
[`tenderEnvelope${envelopeType}NameId`]:
|
||||
tenderData?.[`tenderEnvelope${envelopeType}List`]?.[0]?.[
|
||||
`tenderEnvelope${envelopeType}NameId`
|
||||
],
|
||||
[`envelope${envelopeType}TemplateName`]:
|
||||
tenderData?.[`tenderEnvelope${envelopeType}List`]?.[0]?.[
|
||||
`envelope${envelopeType}TemplateName`
|
||||
],
|
||||
[`save${envelopeType}Data`]: combinedData,
|
||||
deviceType: 'Mobile',
|
||||
// NOTE: echoing the symmetric key back to the server is a weakness of the
|
||||
// current backend contract (see the architecture audit §11) — it cannot be
|
||||
// removed client-side without a coordinated backend change.
|
||||
randomKeyGenerated: encryptionKey,
|
||||
saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
|
||||
};
|
||||
};
|
||||
|
||||
/** Builds the LCS/QCBS payload — only the selected option per question, encrypted. */
|
||||
export const buildQcbsPayload = (
|
||||
tenderData: any,
|
||||
selectedOptions: Record<number, any>,
|
||||
): any | null => {
|
||||
const questions = tenderData?.tenderEnvelopeQcbsList?.questions || [];
|
||||
const encryptionKey = tenderData?.envelopeKeyDetailsResDTO?.envelopeQcbsAndLcs || '';
|
||||
|
||||
const answered = questions.filter((q: any) => selectedOptions[q.questionId]).length;
|
||||
logger.info('envelope', '[payload] building LCS/QCBS payload', {
|
||||
questions: questions.length,
|
||||
answered,
|
||||
hasEncryptionKey: !!encryptionKey,
|
||||
});
|
||||
|
||||
if (!encryptionKey) {
|
||||
logger.error('envelope', 'no encryption key available for LCS/QCBS');
|
||||
return null;
|
||||
}
|
||||
|
||||
const officerEncryptedKeys = buildOfficerEncryptedKeys(
|
||||
encryptionKey,
|
||||
tenderData?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [],
|
||||
);
|
||||
|
||||
const questionPayload = questions.map((question: any) => {
|
||||
const selected = selectedOptions[question.questionId];
|
||||
const parameters = selected
|
||||
? [{
|
||||
option: encrypt(String(selected.option), encryptionKey),
|
||||
mark: selected.mark,
|
||||
strUser: encrypt(String(selected.strUser || '1'), encryptionKey),
|
||||
}]
|
||||
: [];
|
||||
|
||||
return {
|
||||
questionId: question.questionId,
|
||||
question: question.question,
|
||||
parameters,
|
||||
};
|
||||
});
|
||||
|
||||
logger.info('envelope', '[payload] LCS/QCBS payload ready', {
|
||||
questions: questionPayload.length,
|
||||
officers: officerEncryptedKeys.length,
|
||||
});
|
||||
|
||||
return {
|
||||
tenderId: tenderData?.tenderId,
|
||||
templateId: tenderData?.tenderEnvelopeQcbsList?.templateId,
|
||||
qcbsTemplateName: tenderData?.tenderEnvelopeQcbsList?.qcbsTemplateName || 'string',
|
||||
questions: questionPayload,
|
||||
deviceType: 'Mobile',
|
||||
saveEnvelopesOfficersDetailsResDTO: officerEncryptedKeys,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Envelope submission API calls. Uses the legacy `apiCall` envelope contract
|
||||
* (screens branch on `res?.statusCode === 200`) until this feature migrates
|
||||
* to the throwing core/http client end to end.
|
||||
*/
|
||||
import { apiCall } from '../../../Axios/Axios';
|
||||
import {
|
||||
SUBMIT_ENVELOPE_A,
|
||||
SUBMIT_ENVELOPE_B,
|
||||
SUBMIT_ENVELOPE_FILE_A,
|
||||
SUBMIT_ENVELOPE_FILE_B,
|
||||
SUBMIT_QCBS_AND_LCS,
|
||||
} from '../../../Services/ApiCall/ApiUrls';
|
||||
import { logger } from '../../../core/observability/logger';
|
||||
import type { PickedFile } from './envelopeTypes';
|
||||
|
||||
/** A and B share identical flows; only the endpoints differ. */
|
||||
const ENVELOPE_URLS: Record<'A' | 'B', { data: string; file: string }> = {
|
||||
A: { data: SUBMIT_ENVELOPE_A, file: SUBMIT_ENVELOPE_FILE_A },
|
||||
B: { data: SUBMIT_ENVELOPE_B, file: SUBMIT_ENVELOPE_FILE_B },
|
||||
};
|
||||
|
||||
export const uploadEnvelopeDocument = async (
|
||||
envelopeType: 'A' | 'B',
|
||||
requestDto: any,
|
||||
file: PickedFile,
|
||||
): Promise<any | null> => {
|
||||
const url = ENVELOPE_URLS[envelopeType].file;
|
||||
const formData = new FormData();
|
||||
formData.append('files', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
} as any);
|
||||
formData.append('requestDto', JSON.stringify(requestDto));
|
||||
|
||||
logger.info('envelope', `[api] uploading envelope ${envelopeType} document`, {
|
||||
url,
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
try {
|
||||
const res: any = await apiCall('post', url, formData, {}, 'multipart/form-data');
|
||||
logger.info('envelope', `[api] envelope ${envelopeType} document upload responded`, {
|
||||
statusCode: res?.statusCode,
|
||||
});
|
||||
return res;
|
||||
} catch (error) {
|
||||
logger.error('envelope', `file upload failed for envelope ${envelopeType}`, {
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const submitEnvelopeData = async (envelopeType: 'A' | 'B', payload: any): Promise<any> => {
|
||||
const url = ENVELOPE_URLS[envelopeType].data;
|
||||
logger.info('envelope', `[api] submitting envelope ${envelopeType} data`, { url });
|
||||
const res: any = await apiCall('post', url, payload);
|
||||
logger.info('envelope', `[api] envelope ${envelopeType} data submit responded`, {
|
||||
statusCode: res?.statusCode,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const submitQcbsData = async (payload: any): Promise<any> => {
|
||||
logger.info('envelope', '[api] submitting LCS/QCBS data', { url: SUBMIT_QCBS_AND_LCS });
|
||||
const res: any = await apiCall('post', SUBMIT_QCBS_AND_LCS, payload);
|
||||
logger.info('envelope', '[api] LCS/QCBS data submit responded', {
|
||||
statusCode: res?.statusCode,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Shared types for the tender envelope bidding feature. */
|
||||
|
||||
export type EnvelopeType = 'A' | 'B' | 'C' | 'LCS' | 'QCBS';
|
||||
|
||||
/** Per-envelope form values keyed by the backend sequence id. */
|
||||
export type EnvelopeFormState = {
|
||||
A: Record<number, any>;
|
||||
B: Record<number, any>;
|
||||
C: Record<number, any>;
|
||||
LCS: Record<number, any>;
|
||||
QCBS: Record<number, any>;
|
||||
};
|
||||
|
||||
export interface OfficerKey {
|
||||
officersId: number;
|
||||
officerPublicKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
export interface SaveEnvelopeOfficerDetail {
|
||||
officerId: number;
|
||||
officerEncryptedKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
/** A picked document ready for multipart upload. */
|
||||
export type PickedFile = {
|
||||
uri: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number | null;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ export interface LiveTenderTileProps {
|
||||
purchaseEnd?: string;
|
||||
tenderFees?: string | number;
|
||||
isPurchased?: boolean;
|
||||
isCancelled?: boolean;
|
||||
tenderId?: any;
|
||||
tenderNumber?: any;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user