524 lines
16 KiB
JavaScript
524 lines
16 KiB
JavaScript
|
|
// TemplateFormModal.js
|
||
|
|
// Modal for creating, editing, and cloning Archer Risk Acceptance templates.
|
||
|
|
// Supports three modes:
|
||
|
|
// - create: all fields empty
|
||
|
|
// - edit: pre-populated from existing template
|
||
|
|
// - clone: sections pre-populated from source, hierarchy fields empty
|
||
|
|
|
||
|
|
import React, { useState, useEffect, useRef } from 'react';
|
||
|
|
import { X, Save, AlertCircle, Loader } from 'lucide-react';
|
||
|
|
|
||
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Section definitions — ordered as static first, then semi-static
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const SECTIONS = [
|
||
|
|
{ key: 'environment_overview', label: 'Environment Overview' },
|
||
|
|
{ key: 'segmentation', label: 'Segmentation' },
|
||
|
|
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
|
||
|
|
{ key: 'additional_info', label: 'Additional Info/Background' },
|
||
|
|
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
|
||
|
|
{ key: 'data_classification', label: 'Data Classification' },
|
||
|
|
{ key: 'charter_network', label: 'Charter Network' },
|
||
|
|
{ key: 'additional_access_list', label: 'Additional Access List' },
|
||
|
|
];
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Styles
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const STYLES = {
|
||
|
|
backdrop: {
|
||
|
|
position: 'fixed',
|
||
|
|
inset: 0,
|
||
|
|
zIndex: 70,
|
||
|
|
background: 'rgba(10, 14, 39, 0.95)',
|
||
|
|
backdropFilter: 'blur(8px)',
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'flex-start',
|
||
|
|
justifyContent: 'center',
|
||
|
|
padding: '2rem 1rem',
|
||
|
|
overflowY: 'auto',
|
||
|
|
},
|
||
|
|
modal: {
|
||
|
|
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||
|
|
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||
|
|
borderRadius: '12px',
|
||
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(0,212,255,0.08)',
|
||
|
|
width: '100%',
|
||
|
|
maxWidth: '700px',
|
||
|
|
padding: '1.75rem 2rem',
|
||
|
|
marginTop: '1rem',
|
||
|
|
marginBottom: '2rem',
|
||
|
|
},
|
||
|
|
header: {
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
justifyContent: 'space-between',
|
||
|
|
marginBottom: '1.25rem',
|
||
|
|
},
|
||
|
|
title: {
|
||
|
|
fontFamily: 'monospace',
|
||
|
|
fontSize: '0.8rem',
|
||
|
|
fontWeight: 700,
|
||
|
|
color: '#00d4ff',
|
||
|
|
textTransform: 'uppercase',
|
||
|
|
letterSpacing: '0.12em',
|
||
|
|
},
|
||
|
|
closeBtn: {
|
||
|
|
background: 'transparent',
|
||
|
|
border: 'none',
|
||
|
|
color: '#64748B',
|
||
|
|
cursor: 'pointer',
|
||
|
|
padding: '0.25rem',
|
||
|
|
borderRadius: '4px',
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
justifyContent: 'center',
|
||
|
|
},
|
||
|
|
fieldGroup: {
|
||
|
|
marginBottom: '1rem',
|
||
|
|
},
|
||
|
|
label: {
|
||
|
|
display: 'block',
|
||
|
|
fontSize: '0.75rem',
|
||
|
|
fontWeight: 600,
|
||
|
|
color: '#94A3B8',
|
||
|
|
marginBottom: '0.3rem',
|
||
|
|
textTransform: 'uppercase',
|
||
|
|
letterSpacing: '0.05em',
|
||
|
|
},
|
||
|
|
input: {
|
||
|
|
width: '100%',
|
||
|
|
padding: '0.55rem 0.75rem',
|
||
|
|
borderRadius: '6px',
|
||
|
|
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||
|
|
background: 'rgba(15, 23, 42, 0.8)',
|
||
|
|
color: '#e0e0e0',
|
||
|
|
fontSize: '0.85rem',
|
||
|
|
fontFamily: 'inherit',
|
||
|
|
outline: 'none',
|
||
|
|
transition: 'border-color 0.2s',
|
||
|
|
boxSizing: 'border-box',
|
||
|
|
},
|
||
|
|
inputError: {
|
||
|
|
borderColor: '#ef4444',
|
||
|
|
},
|
||
|
|
textarea: {
|
||
|
|
width: '100%',
|
||
|
|
padding: '0.55rem 0.75rem',
|
||
|
|
borderRadius: '6px',
|
||
|
|
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||
|
|
background: 'rgba(15, 23, 42, 0.8)',
|
||
|
|
color: '#e0e0e0',
|
||
|
|
fontSize: '0.82rem',
|
||
|
|
fontFamily: 'inherit',
|
||
|
|
outline: 'none',
|
||
|
|
resize: 'vertical',
|
||
|
|
minHeight: '80px',
|
||
|
|
transition: 'border-color 0.2s',
|
||
|
|
boxSizing: 'border-box',
|
||
|
|
},
|
||
|
|
errorText: {
|
||
|
|
fontSize: '0.72rem',
|
||
|
|
color: '#ef4444',
|
||
|
|
marginTop: '0.2rem',
|
||
|
|
},
|
||
|
|
errorBanner: {
|
||
|
|
padding: '0.65rem 0.85rem',
|
||
|
|
borderRadius: '8px',
|
||
|
|
background: 'rgba(239, 68, 68, 0.1)',
|
||
|
|
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||
|
|
color: '#FCA5A5',
|
||
|
|
fontSize: '0.8rem',
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
gap: '0.5rem',
|
||
|
|
marginBottom: '1rem',
|
||
|
|
},
|
||
|
|
sectionDivider: {
|
||
|
|
margin: '1.25rem 0 0.75rem',
|
||
|
|
padding: '0.4rem 0',
|
||
|
|
borderTop: '1px solid rgba(0, 212, 255, 0.08)',
|
||
|
|
fontSize: '0.7rem',
|
||
|
|
fontWeight: 700,
|
||
|
|
color: '#00d4ff',
|
||
|
|
textTransform: 'uppercase',
|
||
|
|
letterSpacing: '0.12em',
|
||
|
|
},
|
||
|
|
footer: {
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
justifyContent: 'flex-end',
|
||
|
|
gap: '0.75rem',
|
||
|
|
marginTop: '1.5rem',
|
||
|
|
paddingTop: '1rem',
|
||
|
|
borderTop: '1px solid rgba(0, 212, 255, 0.08)',
|
||
|
|
},
|
||
|
|
cancelBtn: {
|
||
|
|
padding: '0.55rem 1.1rem',
|
||
|
|
borderRadius: '6px',
|
||
|
|
border: '1px solid rgba(100,116,139,0.4)',
|
||
|
|
background: 'transparent',
|
||
|
|
color: '#94A3B8',
|
||
|
|
cursor: 'pointer',
|
||
|
|
fontSize: '0.8rem',
|
||
|
|
fontWeight: 600,
|
||
|
|
transition: 'all 0.2s',
|
||
|
|
},
|
||
|
|
submitBtn: {
|
||
|
|
padding: '0.55rem 1.25rem',
|
||
|
|
borderRadius: '6px',
|
||
|
|
border: '1px solid rgba(0, 212, 255, 0.4)',
|
||
|
|
background: 'rgba(0, 212, 255, 0.12)',
|
||
|
|
color: '#7DD3FC',
|
||
|
|
cursor: 'pointer',
|
||
|
|
fontSize: '0.8rem',
|
||
|
|
fontWeight: 600,
|
||
|
|
display: 'inline-flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
gap: '0.4rem',
|
||
|
|
transition: 'all 0.2s',
|
||
|
|
},
|
||
|
|
submitBtnDisabled: {
|
||
|
|
opacity: 0.5,
|
||
|
|
cursor: 'not-allowed',
|
||
|
|
},
|
||
|
|
charCount: {
|
||
|
|
fontSize: '0.65rem',
|
||
|
|
color: '#475569',
|
||
|
|
textAlign: 'right',
|
||
|
|
marginTop: '0.15rem',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Component
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* TemplateFormModal
|
||
|
|
*
|
||
|
|
* Props:
|
||
|
|
* mode {'create'|'edit'|'clone'} Determines form behavior
|
||
|
|
* template {object|null} Source template (for edit/clone)
|
||
|
|
* onClose {function} Callback to close the modal
|
||
|
|
* onSuccess {function} Callback after successful save (refreshes list)
|
||
|
|
*/
|
||
|
|
export default function TemplateFormModal({ mode = 'create', template = null, onClose, onSuccess }) {
|
||
|
|
// Form state
|
||
|
|
const [vendor, setVendor] = useState('');
|
||
|
|
const [platform, setPlatform] = useState('');
|
||
|
|
const [model, setModel] = useState('');
|
||
|
|
const [sections, setSections] = useState(() => {
|
||
|
|
const initial = {};
|
||
|
|
for (const s of SECTIONS) {
|
||
|
|
initial[s.key] = '';
|
||
|
|
}
|
||
|
|
return initial;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Validation and submission state
|
||
|
|
const [fieldErrors, setFieldErrors] = useState({});
|
||
|
|
const [apiError, setApiError] = useState(null);
|
||
|
|
const [submitting, setSubmitting] = useState(false);
|
||
|
|
|
||
|
|
const vendorRef = useRef(null);
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Initialize form based on mode
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
useEffect(() => {
|
||
|
|
if (mode === 'edit' && template) {
|
||
|
|
setVendor(template.vendor || '');
|
||
|
|
setPlatform(template.platform || '');
|
||
|
|
setModel(template.model || '');
|
||
|
|
const sectionValues = {};
|
||
|
|
for (const s of SECTIONS) {
|
||
|
|
sectionValues[s.key] = template[s.key] || '';
|
||
|
|
}
|
||
|
|
setSections(sectionValues);
|
||
|
|
} else if (mode === 'clone' && template) {
|
||
|
|
// Clone: copy sections, leave hierarchy empty
|
||
|
|
setVendor('');
|
||
|
|
setPlatform('');
|
||
|
|
setModel('');
|
||
|
|
const sectionValues = {};
|
||
|
|
for (const s of SECTIONS) {
|
||
|
|
sectionValues[s.key] = template[s.key] || '';
|
||
|
|
}
|
||
|
|
setSections(sectionValues);
|
||
|
|
}
|
||
|
|
// create mode: all fields already empty (initial state)
|
||
|
|
}, [mode, template]);
|
||
|
|
|
||
|
|
// Focus the vendor input on mount
|
||
|
|
useEffect(() => {
|
||
|
|
const timer = setTimeout(() => vendorRef.current?.focus(), 80);
|
||
|
|
return () => clearTimeout(timer);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Handle Escape key
|
||
|
|
useEffect(() => {
|
||
|
|
const handleKey = (e) => {
|
||
|
|
if (e.key === 'Escape') onClose?.();
|
||
|
|
};
|
||
|
|
document.addEventListener('keydown', handleKey);
|
||
|
|
return () => document.removeEventListener('keydown', handleKey);
|
||
|
|
}, [onClose]);
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Validation
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
function validate() {
|
||
|
|
const errors = {};
|
||
|
|
if (!vendor.trim()) errors.vendor = 'Vendor is required';
|
||
|
|
else if (vendor.trim().length > 100) errors.vendor = 'Vendor must be 100 characters or fewer';
|
||
|
|
|
||
|
|
if (!platform.trim()) errors.platform = 'Platform is required';
|
||
|
|
else if (platform.trim().length > 100) errors.platform = 'Platform must be 100 characters or fewer';
|
||
|
|
|
||
|
|
if (!model.trim()) errors.model = 'Model is required';
|
||
|
|
else if (model.trim().length > 100) errors.model = 'Model must be 100 characters or fewer';
|
||
|
|
|
||
|
|
setFieldErrors(errors);
|
||
|
|
return Object.keys(errors).length === 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Submit
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
async function handleSubmit(e) {
|
||
|
|
e.preventDefault();
|
||
|
|
setApiError(null);
|
||
|
|
|
||
|
|
if (!validate()) return;
|
||
|
|
|
||
|
|
setSubmitting(true);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const body = {
|
||
|
|
vendor: vendor.trim(),
|
||
|
|
platform: platform.trim(),
|
||
|
|
model: model.trim(),
|
||
|
|
};
|
||
|
|
|
||
|
|
// Include section fields
|
||
|
|
for (const s of SECTIONS) {
|
||
|
|
body[s.key] = sections[s.key];
|
||
|
|
}
|
||
|
|
|
||
|
|
let url;
|
||
|
|
let method;
|
||
|
|
|
||
|
|
if (mode === 'edit' && template) {
|
||
|
|
// PUT to update
|
||
|
|
url = `${API_BASE}/archer-templates/${template.id}`;
|
||
|
|
method = 'PUT';
|
||
|
|
} else if (mode === 'clone' && template) {
|
||
|
|
// POST to clone endpoint
|
||
|
|
url = `${API_BASE}/archer-templates/${template.id}/clone`;
|
||
|
|
method = 'POST';
|
||
|
|
// Clone endpoint only needs vendor, platform, model
|
||
|
|
delete body.environment_overview;
|
||
|
|
delete body.segmentation;
|
||
|
|
delete body.mitigating_controls;
|
||
|
|
delete body.additional_info;
|
||
|
|
delete body.charter_network_banner;
|
||
|
|
delete body.data_classification;
|
||
|
|
delete body.charter_network;
|
||
|
|
delete body.additional_access_list;
|
||
|
|
} else {
|
||
|
|
// POST to create
|
||
|
|
url = `${API_BASE}/archer-templates`;
|
||
|
|
method = 'POST';
|
||
|
|
}
|
||
|
|
|
||
|
|
const res = await fetch(url, {
|
||
|
|
method,
|
||
|
|
credentials: 'include',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!res.ok) {
|
||
|
|
const data = await res.json().catch(() => ({}));
|
||
|
|
if (res.status === 409) {
|
||
|
|
setApiError(data.error || 'A template with this vendor/platform/model combination already exists');
|
||
|
|
} else {
|
||
|
|
setApiError(data.error || `Request failed (${res.status})`);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Success — close and refresh
|
||
|
|
onSuccess?.();
|
||
|
|
onClose?.();
|
||
|
|
} catch (err) {
|
||
|
|
setApiError(err.message || 'Network error — please try again');
|
||
|
|
} finally {
|
||
|
|
setSubmitting(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Section change handler
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
function handleSectionChange(key, value) {
|
||
|
|
setSections(prev => ({ ...prev, [key]: value }));
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Title based on mode
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
const titles = {
|
||
|
|
create: 'Create Template',
|
||
|
|
edit: 'Edit Template',
|
||
|
|
clone: 'Clone Template',
|
||
|
|
};
|
||
|
|
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
// Render
|
||
|
|
// -------------------------------------------------------------------------
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
aria-labelledby="template-form-modal-title"
|
||
|
|
style={STYLES.backdrop}
|
||
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
|
||
|
|
>
|
||
|
|
<div style={STYLES.modal}>
|
||
|
|
{/* Header */}
|
||
|
|
<div style={STYLES.header}>
|
||
|
|
<span id="template-form-modal-title" style={STYLES.title}>
|
||
|
|
{titles[mode] || 'Template'}
|
||
|
|
</span>
|
||
|
|
<button
|
||
|
|
style={STYLES.closeBtn}
|
||
|
|
onClick={onClose}
|
||
|
|
title="Close"
|
||
|
|
aria-label="Close modal"
|
||
|
|
>
|
||
|
|
<X size={18} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* API error banner */}
|
||
|
|
{apiError && (
|
||
|
|
<div style={STYLES.errorBanner}>
|
||
|
|
<AlertCircle size={14} />
|
||
|
|
{apiError}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<form onSubmit={handleSubmit} noValidate>
|
||
|
|
{/* Hierarchy fields */}
|
||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem' }}>
|
||
|
|
{/* Vendor */}
|
||
|
|
<div style={STYLES.fieldGroup}>
|
||
|
|
<label style={STYLES.label} htmlFor="tmpl-vendor">Vendor *</label>
|
||
|
|
<input
|
||
|
|
ref={vendorRef}
|
||
|
|
id="tmpl-vendor"
|
||
|
|
type="text"
|
||
|
|
maxLength={100}
|
||
|
|
value={vendor}
|
||
|
|
onChange={(e) => {
|
||
|
|
setVendor(e.target.value);
|
||
|
|
if (fieldErrors.vendor) setFieldErrors(prev => ({ ...prev, vendor: undefined }));
|
||
|
|
}}
|
||
|
|
style={{ ...STYLES.input, ...(fieldErrors.vendor ? STYLES.inputError : {}) }}
|
||
|
|
placeholder="e.g. Harmonic"
|
||
|
|
/>
|
||
|
|
{fieldErrors.vendor && <div style={STYLES.errorText}>{fieldErrors.vendor}</div>}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Platform */}
|
||
|
|
<div style={STYLES.fieldGroup}>
|
||
|
|
<label style={STYLES.label} htmlFor="tmpl-platform">Platform *</label>
|
||
|
|
<input
|
||
|
|
id="tmpl-platform"
|
||
|
|
type="text"
|
||
|
|
maxLength={100}
|
||
|
|
value={platform}
|
||
|
|
onChange={(e) => {
|
||
|
|
setPlatform(e.target.value);
|
||
|
|
if (fieldErrors.platform) setFieldErrors(prev => ({ ...prev, platform: undefined }));
|
||
|
|
}}
|
||
|
|
style={{ ...STYLES.input, ...(fieldErrors.platform ? STYLES.inputError : {}) }}
|
||
|
|
placeholder="e.g. vCMTS"
|
||
|
|
/>
|
||
|
|
{fieldErrors.platform && <div style={STYLES.errorText}>{fieldErrors.platform}</div>}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Model */}
|
||
|
|
<div style={STYLES.fieldGroup}>
|
||
|
|
<label style={STYLES.label} htmlFor="tmpl-model">Model *</label>
|
||
|
|
<input
|
||
|
|
id="tmpl-model"
|
||
|
|
type="text"
|
||
|
|
maxLength={100}
|
||
|
|
value={model}
|
||
|
|
onChange={(e) => {
|
||
|
|
setModel(e.target.value);
|
||
|
|
if (fieldErrors.model) setFieldErrors(prev => ({ ...prev, model: undefined }));
|
||
|
|
}}
|
||
|
|
style={{ ...STYLES.input, ...(fieldErrors.model ? STYLES.inputError : {}) }}
|
||
|
|
placeholder="e.g. 3.29.1"
|
||
|
|
/>
|
||
|
|
{fieldErrors.model && <div style={STYLES.errorText}>{fieldErrors.model}</div>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Section textareas */}
|
||
|
|
<div style={STYLES.sectionDivider}>Template Sections</div>
|
||
|
|
|
||
|
|
{SECTIONS.map((section) => (
|
||
|
|
<div key={section.key} style={STYLES.fieldGroup}>
|
||
|
|
<label style={STYLES.label} htmlFor={`tmpl-${section.key}`}>
|
||
|
|
{section.label}
|
||
|
|
</label>
|
||
|
|
<textarea
|
||
|
|
id={`tmpl-${section.key}`}
|
||
|
|
value={sections[section.key]}
|
||
|
|
onChange={(e) => handleSectionChange(section.key, e.target.value)}
|
||
|
|
maxLength={10000}
|
||
|
|
style={STYLES.textarea}
|
||
|
|
placeholder={`Enter ${section.label.toLowerCase()} content...`}
|
||
|
|
/>
|
||
|
|
{sections[section.key].length > 9500 && (
|
||
|
|
<div style={STYLES.charCount}>
|
||
|
|
{sections[section.key].length}/10,000
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Footer actions */}
|
||
|
|
<div style={STYLES.footer}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
style={STYLES.cancelBtn}
|
||
|
|
onClick={onClose}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={submitting}
|
||
|
|
style={{
|
||
|
|
...STYLES.submitBtn,
|
||
|
|
...(submitting ? STYLES.submitBtnDisabled : {}),
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{submitting ? <Loader size={13} /> : <Save size={13} />}
|
||
|
|
{submitting ? 'Saving...' : (mode === 'edit' ? 'Update Template' : 'Save Template')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|