326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
import { useState, memo } from 'react';
|
|
import { isFileBlob, type FileBlob } from '@/lib/4nk/models/Data';
|
|
import { iframeUrl } from "@/app/page";
|
|
import MessageBus from '@/lib/4nk/MessageBus';
|
|
|
|
interface BlockState {
|
|
commited_in: string;
|
|
state_id: string;
|
|
pcd_commitment: Record<string, string>;
|
|
public_data: Record<string, any>;
|
|
}
|
|
|
|
interface Block {
|
|
states: BlockState[];
|
|
}
|
|
|
|
interface Processes {
|
|
[key: string]: Block;
|
|
}
|
|
|
|
interface ProcessesViewerProps {
|
|
processes: Processes | null;
|
|
myProcesses: string[];
|
|
onProcessesUpdate?: (processes: Processes) => void;
|
|
}
|
|
|
|
const compareStates = (
|
|
currentState: BlockState,
|
|
index: number,
|
|
previousState?: BlockState,
|
|
currentPrivateData?: Record<string, any>,
|
|
previousPrivateData?: Record<string, any>
|
|
) => {
|
|
const result: Record<string, {
|
|
value: any,
|
|
status: 'unchanged' | 'modified',
|
|
hash?: string,
|
|
isPrivate: boolean,
|
|
stateId: string
|
|
}> = {};
|
|
|
|
Object.keys(currentState.public_data).forEach(key => {
|
|
const currentValue = currentState.public_data[key];
|
|
const previousValue = previousState?.public_data[key];
|
|
const isModified = index > 0 && previousValue !== undefined && JSON.stringify(currentValue) !== JSON.stringify(previousValue);
|
|
|
|
result[key] = {
|
|
value: currentValue,
|
|
status: isModified ? 'modified' : 'unchanged',
|
|
hash: currentState.pcd_commitment[key],
|
|
isPrivate: false,
|
|
stateId: currentState.state_id
|
|
};
|
|
});
|
|
|
|
if (index === 0 && currentPrivateData) {
|
|
Object.entries(currentPrivateData).forEach(([key, value]) => {
|
|
result[key] = {
|
|
value,
|
|
status: 'unchanged',
|
|
hash: currentState.pcd_commitment[key],
|
|
isPrivate: true,
|
|
stateId: currentState.state_id
|
|
};
|
|
});
|
|
} else if (previousPrivateData) {
|
|
Object.entries(previousPrivateData).forEach(([key, value]) => {
|
|
result[key] = {
|
|
value,
|
|
status: 'unchanged',
|
|
hash: previousState?.pcd_commitment[key],
|
|
isPrivate: true,
|
|
stateId: previousState!.state_id
|
|
};
|
|
});
|
|
if (currentPrivateData) {
|
|
Object.entries(currentPrivateData).forEach(([key, value]) => {
|
|
result[key] = {
|
|
value,
|
|
status: 'modified',
|
|
hash: currentState.pcd_commitment[key],
|
|
isPrivate: true,
|
|
stateId: currentState.state_id
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
function ProcessesViewer({ processes, myProcesses, onProcessesUpdate }: ProcessesViewerProps) {
|
|
const [expandedBlocks, setExpandedBlocks] = useState<string[]>([]);
|
|
const [isFiltered, setIsFiltered] = useState<boolean>(false);
|
|
const [privateData, setPrivateData] = useState<Record<string, Record<string, any>>>({});
|
|
const [editingField, setEditingField] = useState<{processId: string; stateId: string; key: string; value: any;} | null>(null);
|
|
const [tempValue, setTempValue] = useState<any>(null);
|
|
|
|
const toggleBlock = (blockId: string) => {
|
|
setExpandedBlocks(prev => prev.includes(blockId) ? prev.filter(id => id !== blockId) : [...prev, blockId]);
|
|
};
|
|
|
|
const handleFilterClick = () => setIsFiltered(prev => !prev);
|
|
|
|
if (!processes || Object.keys(processes).length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
|
<h3 className="text-lg font-medium mb-2">Aucun processus disponible</h3>
|
|
<p>Connectez-vous pour voir vos processus</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fetchPrivateData = async (processId: string, stateId: string) => {
|
|
if (!expandedBlocks.includes(processId) || !myProcesses.includes(processId)) return;
|
|
try {
|
|
const messageBus = MessageBus.getInstance(iframeUrl);
|
|
await messageBus.isReady();
|
|
const data = await messageBus.getData(processId, stateId);
|
|
setPrivateData(prev => ({ ...prev, [stateId]: data }));
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleDownload = (name: string | undefined, fileBlob: FileBlob) => {
|
|
const blob = new Blob([fileBlob.data], { type: fileBlob.type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name || 'download';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const formatValue = (key: string, value: string | number[] | FileBlob) => {
|
|
if (isFileBlob(value)) {
|
|
return (
|
|
<button className="text-blue-600 hover:underline" onClick={() => handleDownload(key, value)}>
|
|
📥 Télécharger
|
|
</button>
|
|
);
|
|
}
|
|
return <span>{JSON.stringify(value || '')}</span>;
|
|
};
|
|
|
|
const getDataIcon = (value: any) => {
|
|
if (isFileBlob(value)) return '📄';
|
|
if (typeof value === 'string') return '📝';
|
|
if (typeof value === 'number') return '🔢';
|
|
if (Array.isArray(value)) return '📋';
|
|
if (typeof value === 'boolean') return '✅';
|
|
return '📦';
|
|
};
|
|
|
|
const handleFieldUpdate = async (processId: string, stateId: string, key: string, value: any) => {
|
|
try {
|
|
const messageBus = MessageBus.getInstance(iframeUrl);
|
|
await messageBus.isReady();
|
|
const updatedProcess = await messageBus.updateProcess(processId, stateId, { [key]: value }, [], null);
|
|
if (!updatedProcess) throw new Error('No updated process found');
|
|
const newStateId = updatedProcess.diffs[0]?.state_id;
|
|
if (!newStateId) throw new Error('No new state id found');
|
|
await messageBus.notifyProcessUpdate(processId, newStateId);
|
|
await messageBus.validateState(processId, newStateId);
|
|
const updatedProcesses = await messageBus.getProcesses();
|
|
onProcessesUpdate?.(updatedProcesses);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const renderEditForm = (key: string, value: any, onSave: (v: any) => void, onCancel: () => void) => {
|
|
if (tempValue === null) setTempValue(value);
|
|
|
|
const handleFormClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); };
|
|
|
|
if (isFileBlob(value)) {
|
|
return (
|
|
<div className="flex flex-col space-y-2" onClick={handleFormClick}>
|
|
<input type="file" onChange={(e) => {
|
|
e.stopPropagation();
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
if (event.target?.result) {
|
|
setTempValue({ type: file.type, data: new Uint8Array(event.target.result as ArrayBuffer) });
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
}
|
|
}}/>
|
|
<div className="flex space-x-2">
|
|
<button className="px-2 py-1 bg-blue-500 text-white rounded" onClick={() => { onSave(tempValue); setTempValue(null); }}>Sauvegarder</button>
|
|
<button className="px-2 py-1 bg-gray-300 rounded" onClick={() => { onCancel(); setTempValue(null); }}>Annuler</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (typeof value === 'boolean') {
|
|
return (
|
|
<div className="flex items-center space-x-2" onClick={handleFormClick}>
|
|
<select className="border rounded px-2 py-1" value={tempValue.toString()} onChange={(e) => setTempValue(e.target.value === 'true')}>
|
|
<option value="true">Vrai</option>
|
|
<option value="false">Faux</option>
|
|
</select>
|
|
<div className="flex space-x-2">
|
|
<button className="px-2 py-1 bg-blue-500 text-white rounded" onClick={() => onSave(tempValue)}>Sauvegarder</button>
|
|
<button className="px-2 py-1 bg-gray-300 rounded" onClick={() => onCancel()}>Annuler</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return (
|
|
<div className="flex flex-col space-y-2" onClick={handleFormClick}>
|
|
<textarea className="border rounded p-2" rows={4} value={JSON.stringify(tempValue, null, 2)} onChange={(e) => {
|
|
try { const parsed = JSON.parse(e.target.value); if (Array.isArray(parsed)) setTempValue(parsed); } catch {}
|
|
}}/>
|
|
<div className="flex space-x-2">
|
|
<button className="px-2 py-1 bg-blue-500 text-white rounded" onClick={() => onSave(tempValue)}>Sauvegarder</button>
|
|
<button className="px-2 py-1 bg-gray-300 rounded" onClick={() => onCancel()}>Annuler</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex space-x-2 items-center" onClick={handleFormClick}>
|
|
<input className="border rounded px-2 py-1" type={typeof value === 'number' ? 'number' : 'text'} value={tempValue} onChange={(e) => setTempValue(typeof value === 'number' ? parseFloat(e.target.value) : e.target.value)} autoFocus />
|
|
<div className="flex space-x-2">
|
|
<button className="px-2 py-1 bg-blue-500 text-white rounded" onClick={() => onSave(tempValue)}>Sauvegarder</button>
|
|
<button className="px-2 py-1 bg-gray-300 rounded" onClick={() => onCancel()}>Annuler</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderDataField = (key: string, value: any, hash: string | undefined, isPrivate: boolean, processId: string, stateId: string, status: 'unchanged' | 'modified' = 'unchanged', originStateId?: string) => {
|
|
const isEditing = editingField?.key === key && editingField?.processId === processId && editingField?.stateId === stateId;
|
|
return (
|
|
<div key={key} className={`border rounded p-2 mb-2 transition-colors ${status === 'modified' ? 'bg-green-100' : 'bg-white'}`}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center space-x-1">
|
|
<span title={isPrivate ? 'Donnée privée' : 'Donnée publique'}>{isPrivate ? '🔒' : '🌐'}</span>
|
|
<span>{getDataIcon(value)}</span>
|
|
<span className="font-medium">{key}</span>
|
|
{originStateId && originStateId !== stateId && <span title={`Propagé depuis l'état ${originStateId}`}>↺</span>}
|
|
</div>
|
|
<button className="text-sm text-gray-500 hover:text-gray-700" onClick={(e) => { e.stopPropagation(); setEditingField({ processId, stateId, key, value }); }}>
|
|
{isEditing ? '✕' : '🔄'}
|
|
</button>
|
|
</div>
|
|
<div>
|
|
{isEditing ? renderEditForm(key, value, async (v) => { await handleFieldUpdate(processId, stateId, key, v); setEditingField(null); setTempValue(null); }, () => { setEditingField(null); setTempValue(null); }) : (
|
|
<div className="flex items-center space-x-1">
|
|
<span>{formatValue(key, value)}</span>
|
|
{hash && <span title={`Hash: ${hash}`}>🔑</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="w-full h-full overflow-auto p-2">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-semibold">Processus</h2>
|
|
<button className="px-2 py-1 border rounded text-sm" onClick={handleFilterClick}>{isFiltered ? 'Show All' : 'Filter'}</button>
|
|
</div>
|
|
<p className="mb-2 text-sm text-gray-500">{isFiltered ? Object.keys(processes).filter(p => myProcesses.includes(p)).length : Object.keys(processes).length} processus disponible(s)</p>
|
|
<div className="space-y-4">
|
|
{Object.entries(processes).map(([processId, process]) => {
|
|
if (isFiltered && !myProcesses.includes(processId)) return null;
|
|
const isExpanded = expandedBlocks.includes(processId);
|
|
const stateCount = process.states.length - 1;
|
|
|
|
return (
|
|
<div key={processId} className="border rounded shadow-sm">
|
|
<div className="flex justify-between items-center p-2 cursor-pointer bg-gray-50" onClick={() => toggleBlock(processId)}>
|
|
<div className="font-mono">{processId.slice(0,8)}...{processId.slice(-4)}</div>
|
|
<div>{stateCount} état(s)</div>
|
|
<div>{isExpanded ? '▼' : '▶'}</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="p-2 space-y-2 bg-white">
|
|
<div><strong>Process ID:</strong> {processId}</div>
|
|
{process.states.map((state, index) => {
|
|
if (index === stateCount) return null;
|
|
if (myProcesses.includes(processId) && !privateData[state.state_id]) setTimeout(() => fetchPrivateData(processId, state.state_id), 0);
|
|
const statePrivateData = privateData[state.state_id] || {};
|
|
const stateData = compareStates(state, index, index > 0 ? process.states[index-1] : undefined, statePrivateData, index > 0 ? privateData[process.states[index-1].state_id] : undefined);
|
|
|
|
return (
|
|
<div key={`${processId}-state-${index}`} className="border-t pt-2">
|
|
<h4 className="font-medium mb-1">État {index+1}</h4>
|
|
<div className="text-sm mb-1"><strong>TransactionId:</strong> {state.commited_in}</div>
|
|
<div className="text-sm mb-2"><strong>Empreinte totale de l'état:</strong> {state.state_id}</div>
|
|
<div className="space-y-1">
|
|
{Object.entries(stateData).map(([key, { value, status, hash, isPrivate, stateId }]) => renderDataField(key, value, hash, isPrivate, processId, stateId, status, state.state_id))}
|
|
{myProcesses.includes(processId) && Object.keys(statePrivateData).length === 0 && <div className="text-gray-400 text-sm">Chargement des données privées...</div>}
|
|
{!myProcesses.includes(processId) && <div className="text-gray-400 text-sm">🔒 Vous n'avez pas accès aux données privées de ce processus</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ProcessesViewer.displayName = 'ProcessesViewer';
|
|
export default memo(ProcessesViewer);
|