feat: architecture refactor — core layer, spreadsheet engine, envelope bidding, tests
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:
Abin Toppo
2026-07-16 10:20:22 +05:30
co-authored by Claude Fable 5
parent 9acec328a7
commit 1d7646d938
217 changed files with 11068 additions and 6855 deletions
@@ -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',
},
});
+2 -3
View File
@@ -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);
+2 -2
View File
@@ -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;
+13 -6
View File
@@ -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}
+2 -2
View File
@@ -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,
+38 -32
View File
@@ -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';
+8 -6
View File
@@ -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,
+1 -5
View File
@@ -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';
+8 -6
View File
@@ -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 -5
View File
@@ -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);
+3 -3
View File
@@ -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();
+1 -1
View File
@@ -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;
};
+1
View File
@@ -9,6 +9,7 @@ export interface LiveTenderTileProps {
purchaseEnd?: string;
tenderFees?: string | number;
isPurchased?: boolean;
isCancelled?: boolean;
tenderId?: any;
tenderNumber?: any;
}