Presupuesto
Inmoworking Pro
CRM Reformista
${budget.title}
Propiedad: ${budget.property_title} (${budget.ref_code})
Fecha: ${new Date(budget.created_at).toLocaleDateString('es-ES')}
${budget.valid_until ? 'Válido hasta: '+new Date(budget.valid_until).toLocaleDateString('es-ES')+'
' : ''} Estado: ${STATUS_LABELS[budget.status]||budget.status}
${rows}
CategoríaDescripciónUd.Cant.€/udTotal
Subtotal${subtotal.toLocaleString('es-ES',{minimumFractionDigits:2})}€
IVA ${budget.iva_pct}%${iva.toLocaleString('es-ES',{minimumFractionDigits:2})}€
TOTAL${total.toLocaleString('es-ES',{minimumFractionDigits:2})}€
${budget.notes ? `
Notas: ${budget.notes}
` : ''} `; } // ════════════════════════════════════════════════════════════════ // PAGE: DOCUMENTOS // ════════════════════════════════════════════════════════════════ function DocumentosPage() { const [collabs, setCollabs] = useState([]); const [selected, setSelected] = useState(null); const [docs, setDocs] = useState([]); const [loading, setLoading] = useState(true); const [docsLoading, setDocsLoading] = useState(false); useEffect(() => { api('/api/collaborations') .then(d => { const accepted = d.items.filter(c => c.status === 'accepted'); setCollabs(accepted); if (accepted.length) loadDocs(accepted[0]); }) .catch(e => toast(e.message,'error')) .finally(() => setLoading(false)); }, []); const loadDocs = async (collab) => { setSelected(collab); setDocsLoading(true); try { const d = await api(`/api/collaborations/${collab.property_id}/documents`); setDocs(d.items); } catch (e) { toast(e.message,'error'); } finally { setDocsLoading(false); } }; if (loading) return
; if (!collabs.length) return
; return (
{/* Sidebar list */}
Propiedades
{collabs.map(c => ( ))}
{/* Docs list */}
{selected && ( <>
{selected.property_title} {selected.ref_code} · {selected.zone}
{docsLoading ?
: docs.length === 0 ? : (
{docs.map((doc, i) => { const ext = doc.url?.split('.').pop()?.toLowerCase(); const icon = ext === 'pdf' ? '📕' : ['jpg','jpeg','png','gif','webp'].includes(ext) ? '🖼️' : ext === 'xlsx' || ext === 'xls' ? '📊' : '📄'; return (
{icon}
{doc.name}
{doc.type} · {(doc.size_bytes/1024).toFixed(0)} KB · {fmtDate(doc.created_at)} · por {doc.created_by_name}
); })}
) } )}
); } // ════════════════════════════════════════════════════════════════ // PAGE: CUENTA // ════════════════════════════════════════════════════════════════ function CuentaPage() { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [form, setForm] = useState({}); const [pwdForm, setPwdForm] = useState({ current_password:'',new_password:'',confirm:'' }); useEffect(() => { api('/api/users/me') .then(d => { setProfile(d.user); setForm({ full_name:d.user.full_name||'', phone:d.user.phone||'', company:d.user.company||'' }); }) .catch(e => toast(e.message,'error')) .finally(() => setLoading(false)); }, []); const saveProfile = async () => { setSaving(true); try { await api('/api/users/me', { method:'PATCH', body: JSON.stringify(form) }); toast('Perfil actualizado','success'); } catch (e) { toast(e.message,'error'); } finally { setSaving(false); } }; const changePassword = async () => { if (pwdForm.new_password !== pwdForm.confirm) return toast('Las contraseñas no coinciden','error'); if (pwdForm.new_password.length < 8) return toast('Mínimo 8 caracteres','error'); setSaving(true); try { await api('/api/users/me/password', { method:'PATCH', body: JSON.stringify({ current_password:pwdForm.current_password, new_password:pwdForm.new_password }) }); toast('Contraseña actualizada','success'); setPwdForm({ current_password:'',new_password:'',confirm:'' }); } catch (e) { toast(e.message,'error'); } finally { setSaving(false); } }; if (loading) return
; return (

Mi cuenta

🌐 Mi web profesional
Tu landing page pública donde los clientes pueden encontrarte y contactarte.
Ver mi web →
Perfil profesional
setForm(p=>({...p,full_name:e.target.value}))} />
setForm(p=>({...p,phone:e.target.value}))} />
setForm(p=>({...p,company:e.target.value}))} />
Cambiar contraseña
setPwdForm(p=>({...p,current_password:e.target.value}))} />
setPwdForm(p=>({...p,new_password:e.target.value}))} />
setPwdForm(p=>({...p,confirm:e.target.value}))} />
); } const OBRA_STATUS_COLORS = { pendiente:'#9BA5B8', en_curso:'#3B82F6', pausada:'#F59E0B', completada:'#22C55E', cancelada:'#EF4444' }; const OBRA_STATUS_LABELS = { pendiente:'Pendiente', en_curso:'En curso', pausada:'Pausada', completada:'Completada', cancelada:'Cancelada' }; function ObrasPage() { const [obras, setObras] = useState([]); const [loading, setLoading] = useState(true); const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ title:'', client_name:'', address:'', status:'pendiente', start_date:'', end_date:'', total_budget:'', notes:'' }); const load = useCallback(() => { setLoading(true); api('/api/reform-works?limit=100') .then(d => setObras(d.items || [])) .catch(e => toast(e.message, 'error')) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); const save = async () => { if (!form.title.trim()) return toast('Título requerido', 'error'); setSaving(true); try { const method = editing ? 'PUT' : 'POST'; const url = editing ? `/api/reform-works/${editing.id}` : '/api/reform-works'; await api(url, { method, body: JSON.stringify(form) }); toast(editing ? 'Obra actualizada' : 'Obra creada', 'success'); setShowModal(false); setEditing(null); load(); } catch(e) { toast(e.message, 'error'); } finally { setSaving(false); } }; const del = async (id) => { if (!confirm('¿Eliminar esta obra?')) return; try { await api(`/api/reform-works/${id}`, { method:'DELETE' }); toast('Obra eliminada', 'info'); load(); } catch(e) { toast(e.message, 'error'); } }; const patchStatus = async (id, status) => { try { await api(`/api/reform-works/${id}/status`, { method:'PATCH', body: JSON.stringify({ status }) }); setObras(prev => prev.map(o => o.id === id ? {...o, status} : o)); } catch(e) { toast(e.message, 'error'); } }; const openNew = () => { setEditing(null); setForm({ title:'', client_name:'', address:'', status:'pendiente', start_date:'', end_date:'', total_budget:'', notes:'' }); setShowModal(true); }; const openEdit = (obra) => { setEditing(obra); setForm({ title:obra.title, client_name:obra.client_name||'', address:obra.address||'', status:obra.status, start_date:obra.start_date?.slice(0,10)||'', end_date:obra.end_date?.slice(0,10)||'', total_budget:obra.total_budget||'', notes:obra.notes||'' }); setShowModal(true); }; return (

Seguimiento de Obras

Gestiona el estado de tus obras en curso

{loading ? : obras.length === 0 ? + Nueva obra} /> : (
{obras.map((o, i) => { const col = OBRA_STATUS_COLORS[o.status] || '#9BA5B8'; return (
{o.title} {OBRA_STATUS_LABELS[o.status]}
{o.client_name && 👤 {o.client_name}} {o.address && 📍 {o.address}} {o.start_date && ▶ {fmtDate(o.start_date)}} {o.end_date && ⏹ {fmtDate(o.end_date)}} {o.total_budget > 0 && {Number(o.total_budget).toLocaleString('es-ES')}€}
); })}
) } {showModal && (

{editing ? 'Editar obra' : 'Nueva obra'}

setForm(f=>({...f,title:e.target.value}))} placeholder="Ej: Reforma cocina C/ Mayor 5" />
setForm(f=>({...f,client_name:e.target.value}))} />
setForm(f=>({...f,total_budget:e.target.value}))} />
setForm(f=>({...f,address:e.target.value}))} />
setForm(f=>({...f,start_date:e.target.value}))} />
setForm(f=>({...f,end_date:e.target.value}))} />