{"id":9,"date":"2026-04-01T17:06:47","date_gmt":"2026-04-01T17:06:47","guid":{"rendered":"http:\/\/proveedores.seiser.es\/?page_id=9"},"modified":"2026-05-05T15:20:28","modified_gmt":"2026-05-05T15:20:28","slug":"app-proveedores","status":"publish","type":"page","link":"https:\/\/proveedores.seiser.es\/","title":{"rendered":"App Proveedores"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"9\" class=\"elementor elementor-9\">\n\t\t\t\t<div class=\"elementor-element elementor-element-28718c6 e-con-full e-flex e-con e-parent\" data-id=\"28718c6\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-de3b35a elementor-widget elementor-widget-html\" data-id=\"de3b35a\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t\t<link rel=\"preconnect\" href=\"https:\/\/fonts.googleapis.com\"\/>\n<link rel=\"preconnect\" href=\"https:\/\/fonts.gstatic.com\" crossorigin\/>\n<link href=\"https:\/\/fonts.googleapis.com\/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap\" rel=\"stylesheet\"\/>\n<link rel=\"stylesheet\" href=\"\/ecosistema-assets\/styles.css\"\/>\n\n<div id=\"root\"><\/div>\n\n<script src=\"https:\/\/unpkg.com\/react@18.3.1\/umd\/react.development.js\" crossorigin=\"anonymous\"><\/script>\n<script src=\"https:\/\/unpkg.com\/react-dom@18.3.1\/umd\/react-dom.development.js\" crossorigin=\"anonymous\"><\/script>\n<script src=\"https:\/\/unpkg.com\/@babel\/standalone@7.29.0\/babel.min.js\" crossorigin=\"anonymous\"><\/script>\n\n<script src=\"\/ecosistema-assets\/data.js\"><\/script>\n<script type=\"text\/babel\" src=\"\/ecosistema-assets\/tweaks-panel.jsx\"><\/script>\n<script type=\"text\/babel\" src=\"\/ecosistema-assets\/ui-kit.jsx\"><\/script>\n<script type=\"text\/babel\" src=\"\/ecosistema-assets\/views-provider.jsx\"><\/script>\n<script type=\"text\/babel\" src=\"\/ecosistema-assets\/views-admin.jsx\"><\/script>\n\n<script type=\"text\/babel\">\n\nconst API_BASE = 'https:\/\/proveedores.seiser.es\/wp-json\/proveedores\/v1';\nconst CLIENT_COLORS = ['#7c83ff','#ff8a4c','#4cc6c8','#b48cff','#ffc44c','#ff6b9b','#5cd29b','#ff5c5c','#a3a3ff','#ffaa66'];\nfunction colorForClient(name){\n  let h = 0; String(name||'').split('').forEach(ch => h = ((h<<5)-h) + ch.charCodeAt(0));\n  return CLIENT_COLORS[Math.abs(h) % CLIENT_COLORS.length];\n}\nfunction normalizeClient(c){ return { id:c.id, name:c.name, fee:Number(c.fee||0), color:c.color || colorForClient(c.name) }; }\nfunction normalizeEntry(e){ return { ...e, importe:Number(e.importe||0), h:Number(e.h||0), day:Number(e.day||1), anio:Number(e.anio||new Date().getFullYear()) }; }\nfunction normalizeRecurring(r){ return { id:r.id, client:r.client, desc:r.desc || r.description || '', importe:Number(r.importe ?? r.amount ?? 0), month_index:Number(r.month_index ?? 0), anio:Number(r.anio ?? r.year ?? new Date().getFullYear()) }; }\nfunction userFromSession(s){ return { user:s.username, role:s.role, provider:s.provider || (s.username? s.username.charAt(0).toUpperCase()+s.username.slice(1):''), company:s.company, name:s.display_name || s.username || 'Usuario' }; }\n\nwindow.SEISER_API = {\n  token: null,\n  setToken(t){ this.token = t || null; },\n  headers(){ return this.token ? {'X-SG-Token': this.token, 'Authorization':'Bearer '+this.token} : {}; },\n  url(path){\n    if(!this.token) return API_BASE + path;\n    const sep = path.includes('?') ? '&' : '?';\n    return API_BASE + path + sep + 'sg_token=' + encodeURIComponent(this.token);\n  },\n  async login(username, password, app='prov'){\n    const r = await fetch(API_BASE + '\/login', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application\/json'}, body:JSON.stringify({username, password, app})});\n    if(!r.ok) throw new Error('LOGIN HTTP '+r.status);\n    const data = await r.json();\n    if(data && data.token) {\n      this.setToken(data.token);\n      document.cookie = 'sg_token=' + encodeURIComponent(data.token) + '; path=\/; max-age=' + (60*60*24*30) + '; SameSite=Lax';\n    }\n    return data;\n  },\n  async get(path){\n    const r = await fetch(this.url(path), {credentials:'same-origin', headers:this.headers()});\n    if(!r.ok) {\n      let txt = '';\n      try { txt = await r.text(); } catch(e){}\n      throw new Error('GET '+path+' HTTP '+r.status+' '+txt.slice(0,180));\n    }\n    return r.json();\n  },\n  async post(path, body){\n    const r = await fetch(this.url(path), {method:'POST', credentials:'same-origin', headers:{...this.headers(),'Content-Type':'application\/json'}, body:JSON.stringify(body)});\n    if(!r.ok) {\n      let txt = '';\n      try { txt = await r.text(); } catch(e){}\n      throw new Error('POST '+path+' HTTP '+r.status+' '+txt.slice(0,180));\n    }\n    return r.json();\n  },\n  async del(path){\n    const r = await fetch(this.url(path), {method:'DELETE', credentials:'same-origin', headers:this.headers()});\n    if(!r.ok) {\n      let txt = '';\n      try { txt = await r.text(); } catch(e){}\n      throw new Error('DELETE '+path+' HTTP '+r.status+' '+txt.slice(0,180));\n    }\n    return r.json();\n  },\n  async loadAll(){\n    \/\/ Cargamos primero los datos imprescindibles. En algunas versiones antiguas del plugin\n    \/\/ todav\u00eda no existe \/recurring, as\u00ed que no dejamos que eso bloquee toda la app.\n    const [entries, clients, paid] = await Promise.all([\n      this.get('\/entries'),\n      this.get('\/clients'),\n      this.get('\/paid')\n    ]);\n\n    let recurring = [];\n    try {\n      recurring = await this.get('\/recurring');\n    } catch(e) {\n      console.warn('Endpoint \/recurring no disponible. Se contin\u00faa sin gastos recurrentes.', e);\n      recurring = [];\n    }\n\n    return {\n      entries:(entries||[]).map(normalizeEntry),\n      clients:(clients||[]).map(normalizeClient),\n      paid:paid||{},\n      recurring:(recurring||[]).map(normalizeRecurring)\n    };\n  },\n  async createEntry(payload){ return normalizeEntry(await this.post('\/entries', payload)); },\n  async updateEntry(id, payload){ return normalizeEntry(await this.post('\/entry-update', {...payload, id})); },\n  async deleteEntry(id){ return this.del('\/entries\/'+id); },\n  async createClient(payload){\n    return normalizeClient(await this.post('\/clients', {name:payload.name, fee:payload.fee||0, monthly_fee:payload.fee||0}));\n  },\n  async updateClient(id, payload){\n    return normalizeClient(await this.post('\/clients\/'+id, {name:payload.name, fee:payload.fee, monthly_fee:payload.fee}));\n  },\n  async deleteClient(id){ return this.del('\/clients\/'+id); },\n  async createRecurring(payload){ return normalizeRecurring(await this.post('\/recurring', {client:payload.client, description:payload.desc, amount:payload.importe, month_index:payload.month_index, year:payload.anio})); },\n  async updateRecurring(id, payload){ return normalizeRecurring(await this.post('\/recurring\/'+id, {client:payload.client, description:payload.desc, amount:payload.importe, month_index:payload.month_index, year:payload.anio})); },\n  async deleteRecurring(id){ return this.del('\/recurring\/'+id); },\n  async savePaid({key, value}){\n    const parts = key.split('_');\n    const year = Number(parts[0]);\n    const month_index = Number(parts[1]);\n    const company = parts[2];\n    const provider = parts.slice(3).join('_');\n    return this.post('\/paid', {year, month_index, company, provider, is_paid:value?1:0});\n  }\n};\n\nconst TWEAK_DEFAULTS = \/*EDITMODE-BEGIN*\/{\n  \"tone\": \"calma\",\n  \"density\": \"comoda\",\n  \"showSparklines\": true,\n  \"useEur\": true\n}\/*EDITMODE-END*\/;\n\n\/\/ Sidebar\nconst Sidebar = ({ user, route, setRoute, onLogout }) => {\n  const isAdmin = user.role === 'admin';\n  const NAV_ADMIN = [\n    { id: 'dashboard', label: 'Resumen', icon: 'home' },\n    { id: 'pagos', label: 'Pagos', icon: 'coins' },\n    { id: 'clientes', label: 'Clientes', icon: 'users' },\n    { id: 'config', label: 'Configuraci\u00f3n', icon: 'settings' },\n  ];\n  const NAV_PROV = [\n    { id: 'inicio', label: 'Mis trabajos', icon: 'layout' },\n  ];\n  const items = isAdmin ? NAV_ADMIN : NAV_PROV;\n  const COMPANY_NAME = { brego: 'Brego Studio', braven: 'Braven', iker: 'Iker \u00b7 Dise\u00f1o', seiser: 'Grupo SEISER' };\n  return (\n    <aside className=\"sidebar\">\n      <div className=\"sb-brand\">\n        <div className=\"sb-logo\">SG<\/div>\n        <div>\n          <div style={{fontWeight:600, fontSize:13, letterSpacing:'-0.01em'}}>Grupo SEISER<\/div>\n          <div style={{fontSize:10.5, color:'var(--t3)', textTransform:'uppercase', letterSpacing:'0.06em'}}>Proveedores<\/div>\n        <\/div>\n      <\/div>\n\n      <div className=\"sb-section-lbl\">Espacio de trabajo<\/div>\n      {items.map(it => (\n        <button key={it.id} className={`sb-nav ${route === it.id ? 'on' : ''}`} onClick={()=>setRoute(it.id)}>\n          <Icon name={it.icon} size={15}\/>\n          <span>{it.label}<\/span>\n        <\/button>\n      ))}\n\n      <div className=\"sb-spacer\"\/>\n\n      <div className=\"sb-user\">\n        <div className=\"sb-user-avatar\">{initials(user.name)}<\/div>\n        <div className=\"sb-user-info\">\n          <div className=\"sb-user-name\">{user.name}<\/div>\n          <div className=\"sb-user-meta\">{COMPANY_NAME[user.company] || user.company}<\/div>\n        <\/div>\n        <button className=\"sb-logout\" onClick={onLogout} title=\"Cerrar sesi\u00f3n\">\n          <Icon name=\"logout\" size={14}\/>\n        <\/button>\n      <\/div>\n    <\/aside>\n  );\n};\n\nconst App = () => {\n  const [tweaks, setTweak] = window.useTweaks ? useTweaks(TWEAK_DEFAULTS) : [TWEAK_DEFAULTS, ()=>{}];\n  const [user, setUser] = React.useState(null);\n  const [route, setRoute] = React.useState('dashboard');\n  const today = new Date();\n  const [vm, setVm] = React.useState(today.getMonth());\n  const [vy, setVy] = React.useState(today.getFullYear());\n  const [data, setData] = React.useState(() => ({\n    entries: [],\n    clients: [],\n    recurring: [],\n    paid: {}\n  }));\n  const [loadingData, setLoadingData] = React.useState(false);\n\n  \/\/ aplicar tone a body\n  React.useEffect(() => {\n    document.body.dataset.tone = tweaks.tone || 'calma';\n    document.body.dataset.density = tweaks.density || 'comoda';\n  }, [tweaks.tone, tweaks.density]);\n\n  const chMonth = (delta) => {\n    let nm = vm + delta, ny = vy;\n    if(nm < 0){ nm = 11; ny--; }\n    if(nm > 11){ nm = 0; ny++; }\n    setVm(nm); setVy(ny);\n  };\n  const store = { ...data, chMonth };\n  const setStore = (next) => {\n    const { chMonth: _omit, ...rest } = next;\n    setData(rest);\n  };\n\n  const handleLogin = async (session) => {\n    setLoadingData(true);\n    try{\n      if(session?.token) window.SEISER_API.setToken(session.token);\n      const realData = await window.SEISER_API.loadAll();\n      setData(realData);\n      const u = userFromSession(session);\n      setUser(u);\n      setRoute(u.role === 'admin' ? 'dashboard' : 'inicio');\n    }catch(e){\n      console.error(e);\n      alert('Login correcto, pero no se pudieron cargar los datos reales. Error: ' + (e && e.message ? e.message : e));\n    }finally{\n      setLoadingData(false);\n    }\n  };\n\n  if(!user){\n    return <Login onLogin={handleLogin}\/>;\n  }\n  if(loadingData){\n    return <div className=\"login-wrap\"><div className=\"login-form-side\"><div className=\"login-form\"><h2>Cargando datos reales\u2026<\/h2><div className=\"sub\">Conectando con WordPress<\/div><\/div><\/div><\/div>;\n  }\n\n  return (\n    <div className=\"app\">\n      <Sidebar user={user} route={route} setRoute={setRoute} onLogout={()=>setUser(null)}\/>\n      <main className=\"main\">\n        <div className=\"page\">\n          {user.role === 'admin' ? (\n            <>\n              {route === 'dashboard' && <AdminDashboard store={store} setStore={setStore} vm={vm} vy={vy} onNav={setRoute}\/>}\n              {route === 'pagos' && <AdminPagos store={store} setStore={setStore} vm={vm} vy={vy}\/>}\n              {route === 'clientes' && <AdminClientes store={store} vm={vm} vy={vy}\/>}\n              {route === 'config' && <AdminConfig store={store} setStore={setStore}\/>}\n            <\/>\n          ) : (\n            <ProviderView user={user} store={store} setStore={setStore} vm={vm} vy={vy}\/>\n          )}\n        <\/div>\n      <\/main>\n\n      {window.TweaksPanel && (\n        <TweaksPanel title=\"Tweaks\">\n          <TweakSection title=\"Tono visual\">\n            <TweakRadio label=\"Tono\"\n              options={[{value:'calma', label:'Calma'},{value:'vivo', label:'Vivo'}]}\n              value={tweaks.tone} onChange={v=>setTweak('tone', v)}\/>\n          <\/TweakSection>\n          <TweakSection title=\"Densidad\">\n            <TweakRadio label=\"Espaciado\"\n              options={[{value:'comoda', label:'C\u00f3moda'},{value:'compacta', label:'Compacta'}]}\n              value={tweaks.density} onChange={v=>setTweak('density', v)}\/>\n          <\/TweakSection>\n        <\/TweaksPanel>\n      )}\n    <\/div>\n  );\n};\n\nReactDOM.createRoot(document.getElementById('root')).render(<App\/>);\n<\/script>\n\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_header_footer","meta":{"footnotes":""},"class_list":["post-9","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/pages\/9","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/comments?post=9"}],"version-history":[{"count":103,"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/pages\/9\/revisions"}],"predecessor-version":[{"id":134,"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/pages\/9\/revisions\/134"}],"wp:attachment":[{"href":"https:\/\/proveedores.seiser.es\/index.php\/wp-json\/wp\/v2\/media?parent=9"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}