// ============================================================ // TimeTrack – app.jsx // Zmartwebbreklam – tid.zmartwebbreklam.se // ============================================================ const { useState, useEffect, useCallback } = React; // ─── Konfig ─────────────────────────────────────────────────────────────────── const API = '/api.php'; // ─── Team ───────────────────────────────────────────────────────────────────── const TEAM = ["Fredrik", "Pelle", "Frida", "Buskenström", "Niklas E", "Linn", "Adam"]; // ─── Helpers ────────────────────────────────────────────────────────────────── const fmtSEK = n => new Intl.NumberFormat("sv-SE",{style:"currency",currency:"SEK",maximumFractionDigits:0}).format(n); const fmtDate = d => d ? new Date(d).toLocaleDateString("sv-SE") : "–"; const hoursFor = (entries, pid) => entries.filter(e=>e.project_id===pid).reduce((s,e)=>s+Number(e.hours),0); const daysLeft = d => d ? Math.ceil((new Date(d)-new Date())/86400000) : null; const PALETTE = ["#B45309","#065F46","#1D4ED8","#7C3AED","#BE185D","#0F766E","#B91C1C","#1E40AF"]; // ─── Token-hantering ────────────────────────────────────────────────────────── function getToken() { return localStorage.getItem('tt_token') || ''; } function setToken(user, pass) { localStorage.setItem('tt_token', btoa(user + ':' + pass)); } function clearToken() { localStorage.removeItem('tt_token'); } // ─── API-anrop ──────────────────────────────────────────────────────────────── async function apiFetch(resource, method="GET", body=null) { const opts = { method, headers: { "Content-Type": "application/json", "X-Auth-Token": getToken() } }; if (body) opts.body = JSON.stringify(body); const r = await fetch(`${API}/${resource}`, opts); if (r.status === 401) { clearToken(); throw new Error('__LOGOUT__'); } if (!r.ok) { const e = await r.json().catch(()=>({error:r.statusText})); throw new Error(e.error||r.statusText); } return r.json(); } // ─── Inloggningsskärm ───────────────────────────────────────────────────────── function LoginScreen({ onLogin }) { const [user, setUser] = useState(''); const [pass, setPass] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const attempt = async () => { if (!user || !pass) return; setLoading(true); setError(''); setToken(user, pass); try { await apiFetch('clients'); onLogin(); } catch(e) { clearToken(); setError('Fel användarnamn eller lösenord'); } finally { setLoading(false); } }; const onKey = e => { if (e.key === 'Enter') attempt(); }; return (
T/
Zmartwebbreklam TimeTrack
setUser(e.target.value)} onKeyDown={onKey} placeholder='användarnamn' autoFocus autoComplete='username' />
setPass(e.target.value)} onKeyDown={onKey} placeholder='••••••••' autoComplete='current-password' />
{error &&
{error}
}
); } // ══════════════════════════════════════════════════════════════════════════════ function App() { const [loggedIn, setLoggedIn] = useState(() => !!getToken()); const [view, setView] = useState("dashboard"); const [clients, setClients] = useState([]); const [projects, setProjects] = useState([]); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [currentUser, setCurrentUser] = useState(() => localStorage.getItem("tt_user")||TEAM[0]); if (!loggedIn) return setLoggedIn(true)} />; const [showNewClient, setShowNewClient] = useState(false); const [showNewProject, setShowNewProject] = useState(false); const [showNewEntry, setShowNewEntry] = useState(false); const [editProject, setEditProject] = useState(null); const [detailProject, setDetailProject] = useState(null); // ── Ladda all data från API ── const loadAll = useCallback(async () => { try { setLoading(true); setError(null); const [c, p, e] = await Promise.all([ apiFetch('clients'), apiFetch('projects'), apiFetch('entries'), ]); setClients(c); setProjects(p); setEntries(e); } catch(err) { if (err.message === '__LOGOUT__') { clearToken(); setLoggedIn(false); return; } setError("Kunde inte ansluta till servern: " + err.message); } finally { setLoading(false); } }, []); useEffect(() => { loadAll(); }, [loadAll]); useEffect(() => { localStorage.setItem("tt_user", currentUser); }, [currentUser]); // ── CRUD helpers ── const addClient = async (data) => { await apiFetch('clients','POST', data); await loadAll(); setShowNewClient(false); }; const addProject = async (data) => { await apiFetch('projects','POST', data); await loadAll(); setShowNewProject(false); }; const updateProject = async (id, data) => { await apiFetch(`projects/${id}`,'PUT', data); await loadAll(); setEditProject(null); }; const addEntry = async (data) => { await apiFetch('entries','POST', data); await loadAll(); setShowNewEntry(false); }; const deleteEntry = async (id) => { await apiFetch(`entries/${id}`,'DELETE'); await loadAll(); }; const updateEntry = async (id, data) => { await apiFetch(`entries/${id}`,'PUT', data); await loadAll(); }; const deleteProject = async (id) => { await apiFetch(`projects/${id}`,'DELETE'); setDetailProject(null); await loadAll(); }; const deleteClient = async (id) => { await apiFetch(`clients/${id}`,'DELETE'); await loadAll(); }; const toggleInvoiced = async (projectId) => { const updated = await apiFetch('toggle-invoiced','POST',{id:projectId}); setProjects(prev => prev.map(p => p.id===projectId ? {...p,...updated} : p)); setDetailProject(prev => prev?.id===projectId ? {...prev,...updated} : prev); }; // ── KPI ── const totalValue = projects.reduce((s,p)=>s+Number(p.value||0),0); const invoicedValue = projects.filter(p=>p.invoiced).reduce((s,p)=>s+Number(p.value||0),0); const totalHours = entries.reduce((s,e)=>s+Number(e.hours),0); if (loading) return (
T/
Ansluter till databasen…
); if (error) return (
⚠️
Anslutningsfel
{error}
); return (
{/* ── Sidebar ── */} {/* ── Main ── */}
{view==="dashboard" && } {view==="report" && } {view==="projects" && setShowNewProject(true)} onEdit={setEditProject} onDetail={setDetailProject} onToggleInvoiced={toggleInvoiced} onDelete={deleteProject}/>} {view==="clients" && setShowNewClient(true)} onDelete={deleteClient}/>} {view==="log" && } {view==="reports" && }
{showNewClient && setShowNewClient(false)} onSave={addClient}/>} {showNewProject && setShowNewProject(false)} onSave={addProject}/>} {editProject && setEditProject(null)} onSave={data=>updateProject(editProject.id, data)}/>} {showNewEntry && setShowNewEntry(false)} onSave={addEntry}/>} {detailProject && c.id===detailProject.client_id)} entries={entries.filter(e=>e.project_id===detailProject.id)} onClose={()=>setDetailProject(null)} onToggleInvoiced={toggleInvoiced} onDelete={deleteProject} onEdit={p=>{setDetailProject(null);setEditProject(p);}}/>}
); } // ── Dashboard ───────────────────────────────────────────────────────────────── function Dashboard({clients,projects,entries,totalHours,totalValue,invoicedValue,currentUser,onOpenProject,onToggleInvoiced}) { const myHours = entries.filter(e=>e.person===currentUser).reduce((s,e)=>s+Number(e.hours),0); const recent = [...projects].sort((a,b)=>new Date(b.created_at)-new Date(a.created_at)).slice(0,6); return (

Välkommen, {currentUser} 👋

{new Date().toLocaleDateString("sv-SE",{weekday:"long",year:"numeric",month:"long",day:"numeric"})}

p.invoiced).length} fakturerade`} accent="#065F46"/> !p.invoiced).length} projekt`} accent="#7C3AED"/>

Senaste projekt

{recent.map(p=>{ const cl=clients.find(c=>c.id===p.client_id); const spent=hoursFor(entries,p.id); const pct=p.estimated_hours?Math.min(100,Math.round(spent/p.estimated_hours*100)):0; const days=daysLeft(p.deadline); return (
onOpenProject(p)}>
{cl?.name||"Okänd"} {p.invoiced ? ✓ Fakturerad : }
{p.name}
{fmtSEK(p.value||0)}
90?"#DC2626":(cl?.color||"#1D4ED8")}}/>
{spent}h / {p.estimated_hours||"?"}h
{days!==null&&
{days<0?`Försenad ${Math.abs(days)} dagar`:days===0?"Deadline idag!":`${days} dagar kvar`}
}
); })}
); } function KpiCard({label,value,sub,accent}) { return
{value}
{label}
{sub}
; } // ── Report ──────────────────────────────────────────────────────────────────── function ReportView({clients,projects,entries,currentUser,onAdd}) { const [form,setForm]=useState({client_id:"",project_id:"",hours:"",date:new Date().toISOString().slice(0,10),note:"",person:currentUser}); const [saving,setSaving]=useState(false); const [saved,setSaved]=useState(false); useEffect(()=>setForm(f=>({...f,person:currentUser})),[currentUser]); const fp = form.client_id ? projects.filter(p=>p.client_id===form.client_id) : projects; const submit = async () => { if (!form.project_id||!form.hours||!form.date) return; setSaving(true); await onAdd({project_id:form.project_id,person:form.person,hours:Number(form.hours),date:form.date,note:form.note}); setForm(f=>({...f,project_id:"",hours:"",note:""})); setSaving(false); setSaved(true); setTimeout(()=>setSaved(false),2500); }; return (

Rapportera tid

setForm(f=>({...f,date:e.target.value}))}/> setForm(f=>({...f,hours:e.target.value}))} placeholder="0.0"/> setForm(f=>({...f,note:e.target.value}))} placeholder="Vad arbetade du med?"/>
{saved&&✓ Sparad!}

Mina senaste poster

{["Datum","Projekt","Kund","Timmar","Notat"].map(h=>)} {entries.filter(e=>e.person===currentUser).slice(0,15).map(e=>{ const proj=projects.find(p=>p.id===e.project_id); const cl=proj?clients.find(c=>c.id===proj.client_id):null; return ; })}
{h}
{fmtDate(e.date)} {proj?.name||"–"} {cl&&{cl.name}} {e.hours} h {e.note||"–"}
); } // ── Projects ────────────────────────────────────────────────────────────────── function ProjectsView({clients,projects,entries,onNew,onEdit,onDetail,onToggleInvoiced,onDelete}) { const [fc,setFc]=useState(""); const [fi,setFi]=useState("all"); const [search,setSearch]=useState(""); const [confirmDelete,setConfirmDelete]=useState(null); const filtered=projects.filter(p=>{ if(fc&&p.client_id!==fc)return false; if(fi==="invoiced"&&!p.invoiced)return false; if(fi==="not_invoiced"&&p.invoiced)return false; if(search&&!p.name.toLowerCase().includes(search.toLowerCase()))return false; return true; }); return (

Projekt

setSearch(e.target.value)}/>
{["Projekt","Kund","Offererat","Timmar","Deadline","Faktura","",""].map(h=>)} {filtered.map(p=>{ const cl=clients.find(c=>c.id===p.client_id); const spent=hoursFor(entries,p.id); const pct=p.estimated_hours?Math.min(100,Math.round(spent/p.estimated_hours*100)):null; const days=daysLeft(p.deadline); return onDetail(p)}> ; })}
{h}
{p.name}
{p.description&&
{p.description.slice(0,50)}{p.description.length>50?"…":""}
}
{cl&&{cl.name}} {fmtSEK(p.value||0)} {spent} h {p.estimated_hours?`/ ${p.estimated_hours}h`:""} {pct!==null&&
90?"#DC2626":(cl?.color||"#1D4ED8")}}/>
}
{fmtDate(p.deadline)} e.stopPropagation()}> {p.invoiced ?
✓ Fakturerad
{fmtDate(p.invoiced_at)}
:}
e.stopPropagation()}> e.stopPropagation()}>
{confirmDelete && { onDelete(confirmDelete.id); setConfirmDelete(null); }} onCancel={()=>setConfirmDelete(null)} />}
); } // ── Clients ─────────────────────────────────────────────────────────────────── function ClientsView({clients,projects,entries,onNew,onDelete}) { const [confirmDelete,setConfirmDelete]=useState(null); return (

Kunder

{clients.map(c=>{ const cp=projects.filter(p=>p.client_id===c.id); const ch=cp.reduce((s,p)=>s+hoursFor(entries,p.id),0); const cv=cp.reduce((s,p)=>s+Number(p.value||0),0); return
{c.name}
{cp.length}projekt
{ch}htimmar
{fmtSEK(cv)}offererat
{cp.map(p=>
{p.name} {p.invoiced&&}
)}
; })}
{confirmDelete && { onDelete(confirmDelete.id); setConfirmDelete(null); }} onCancel={()=>setConfirmDelete(null)} />}
); } // ── Log ─────────────────────────────────────────────────────────────────────── function LogView({clients,projects,entries,onDelete,onUpdate}) { const [fp,setFp]=useState(""); const [fc,setFc]=useState(""); const [fpr,setFpr]=useState(""); const [editEntry,setEditEntry]=useState(null); const fProjects=fc?projects.filter(p=>p.client_id===fc):projects; const filtered=[...entries].sort((a,b)=>new Date(b.date)-new Date(a.date)).filter(e=>{ if(fp&&e.person!==fp)return false; if(fpr&&e.project_id!==fpr)return false; if(fc){const p=projects.find(p=>p.id===e.project_id);if(!p||p.client_id!==fc)return false;} return true; }); const total=filtered.reduce((s,e)=>s+Number(e.hours),0); return (

Tidlogg

{total} h {filtered.length} poster
{["Datum","Medarbetare","Projekt","Kund","Timmar","Notat","",""].map(h=>)} {filtered.map(e=>{ const proj=projects.find(p=>p.id===e.project_id); const cl=proj?clients.find(c=>c.id===proj.client_id):null; return ; })}
{h}
{fmtDate(e.date)} {e.person} {proj?.name||"–"}{proj?.invoiced&&} {cl&&{cl.name}} {e.hours} h {e.note||"–"}
{editEntry && setEditEntry(null)} onSave={async(data)=>{ await onUpdate(editEntry.id, data); setEditEntry(null); }} />}
); } // ── Modals ──────────────────────────────────────────────────────────────────── function Modal({title,onClose,children,wide}) { return

{title}

{children}
; } function Fg({label,children,full}) { return
{children}
; } function NewClientModal({onClose,onSave}) { const [name,setName]=useState(""); const [color,setColor]=useState(PALETTE[Math.floor(Math.random()*PALETTE.length)]); const [saving,setSaving]=useState(false); return setName(e.target.value)} placeholder="Företagsnamn…" autoFocus/>
{PALETTE.map(c=>
setColor(c)} style={{width:28,height:28,borderRadius:"50%",background:c,cursor:"pointer",outline:color===c?"3px solid #111":"none",outlineOffset:2}}/>)}
; } function NewProjectModal({clients,project,onClose,onSave}) { const [form,setForm]=useState({ client_id:project?.client_id||(clients[0]?.id||""), name:project?.name||"",description:project?.description||"", value:project?.value||"",estimated_hours:project?.estimated_hours||"", deadline:project?.deadline||"",file_name:project?.file_name||null, }); const [saving,setSaving]=useState(false); return
setForm(f=>({...f,name:e.target.value}))} placeholder="Kampanjnamn…" autoFocus/>