// ============================================================================ // Beszerzés — a projektbe megvásárolandó tételek listája // ---------------------------------------------------------------------------- // Minden tétel egy FÁZIS-hoz és — ha van — egy KÖLTSÉGVETÉSI TÉTEL-hez // (parent task) tartozik, ezért a lista a költségvetés szerkezete szerint // olvasható: melyik keretből mit tervezünk megvenni, és miből mennyi ment el. // // Rögzítés kétféleképp: // • kézzel — a mezők kitöltésével; // • linkkel — a termék linkjét beillesztve a szerver kiolvassa a nevet, a // leírást, az árat és a képet (api/procurement/fetch-link.php). // // KÉTNYELVŰSÉG: a rögzítő a SAJÁT nyelvén ír (egyetlen megnevezés/leírás mező), // a másik nyelvű változatot a szerver készíti el mentéskor. A listában és a // kártyákon mindenki a saját nyelvén látja a tételt — magyar felületen magyarul, // angolon angolul. // // Állapotok: Előválogatva → Kiválasztva → Megvásárolva → Beépítve. // // FONTOS: ez tervezési/bevásárló lista, nem könyvelés. A tényleges költés // továbbra is a jóváhagyott teljesítésekből jön; a beszerzési összegek külön // jelennek meg, és nem módosítják a Költségvetés jóváhagyott számait. // ============================================================================ // Gyakori mértékegységek — a mennyiség melletti mezőhöz ajánlásként. const PROC_UNITS = ['db', 'csomag', 'készlet', 'm', 'm²', 'm³', 'kg', 'liter']; // Állapot-pasztilla. Szerkesztési joggal gombként viselkedik (állapotváltó). const ProcStatusPill = ({ status, onClick, small = false }) => { const color = window.PROC_STATUS_COLORS[status] || 'var(--ink-3)'; const label = window.procStatusLabel(status); const style = { display: 'inline-flex', alignItems: 'center', gap: 5, flexShrink: 0, background: color + '1F', color, border: 'none', padding: small ? '2px 8px' : '4px 10px', fontSize: small ? 10 : 11, fontWeight: 700, borderRadius: 999, cursor: onClick ? 'pointer' : 'default', whiteSpace: 'nowrap', }; const inner = ( <> {label} {onClick && } ); if (!onClick) return {inner}; return ( ); }; // Termékkép-bélyeg. Kép hiányában semleges helyőrző (kosár ikon). const ProcThumb = ({ item, size = 56 }) => { const [failed, setFailed] = React.useState(false); const box = { width: size, height: size, flexShrink: 0, borderRadius: 'var(--r-sm)', background: 'var(--surface-2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--ink-4)', overflow: 'hidden', }; if (!item.imageUrl || failed) { return
; } return (
setFailed(true)} style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
); }; // Egy tétel sora a listában. const ProcurementRow = ({ item, canEdit, onOpen, onStatus, busy }) => { const task = window.getParentTask(item.parentTaskId); const total = window.procTotal(item); const qtyLabel = window.procQuantityLabel(item); return (
onOpen(item)} style={{ padding: 12, display: 'flex', gap: 12, alignItems: 'flex-start', cursor: 'pointer', opacity: busy ? 0.5 : 1 }}>
{window.procName(item)}
{window.formatHufShort(total)} Ft
{`${qtyLabel} × ${window.formatHufShort(item.unitPriceHuf)} Ft`} {item.currency !== 'HUF' && ` (${item.unitPrice} ${item.currency})`}
{/* A leírás egy sorban, levágva — a teljes szöveg a tétel megnyitásakor. */} {window.procDescription(item) && (
{window.procDescription(item)}
)}
e.stopPropagation()}> onStatus(item) : null}/> {task && ( {task.name} )} {item.vendor && {item.vendor}} {item.url && ( e.stopPropagation()} title="Termék megnyitása" style={{ color: 'var(--accent-d)', display: 'inline-flex', alignItems: 'center' }}> )}
); }; // ─── fő nézet ────────────────────────────────────────────────────────────── const Procurement = ({ user, onToast }) => { const [query, setQuery] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState('all'); const [phaseFilter, setPhaseFilter] = React.useState('all'); const [taskFilter, setTaskFilter] = React.useState('all'); const [sheet, setSheet] = React.useState(null); // { item } | { item: null } const [statusFor, setStatusFor] = React.useState(null); // állapotváltó lap const [busyId, setBusyId] = React.useState(null); const canEdit = !!(window.hasPerm && window.hasPerm('procurement.edit', user, true)); const items = window.PROCUREMENT || []; const toast = (msg) => (onToast ? onToast(msg) : null); // Szűrés: állapot + fázis + költségvetési tétel + szabad szavas keresés. const filtered = items.filter(it => { if (statusFilter !== 'all' && it.status !== statusFilter) return false; if (phaseFilter !== 'all' && String(it.phaseId) !== String(phaseFilter)) return false; if (taskFilter !== 'all' && String(it.parentTaskId || '') !== String(taskFilter)) return false; return window.procMatchesQuery(it, query); }); // Fázisonként csoportosítva, a fázisok sorrendjében; egy fázison belül a // költségvetési tételek sorrendje, azon belül a legújabb elöl. const taskOrder = new Map((window.PARENT_TASKS || []).map((t, i) => [t.id, i])); const groups = (window.PHASES || []) .map(p => ({ phase: p, items: filtered.filter(it => it.phaseId === p.id).sort((a, b) => { const ta = taskOrder.has(a.parentTaskId) ? taskOrder.get(a.parentTaskId) : 9999; const tb = taskOrder.has(b.parentTaskId) ? taskOrder.get(b.parentTaskId) : 9999; if (ta !== tb) return ta - tb; return b.id - a.id; }), })) .filter(g => g.items.length > 0); // Az összesítő a SZŰRT listára vonatkozik, hogy a keresés eredménye is // azonnal számot adjon. const sumOf = (list) => list.reduce((s, it) => s + window.procTotal(it), 0); const statTotals = window.PROC_STATUSES.map(s => { const list = filtered.filter(it => it.status === s); return { status: s, count: list.length, sum: sumOf(list) }; }); const grandTotal = sumOf(filtered); const tasksForFilter = phaseFilter === 'all' ? (window.PARENT_TASKS || []) : (window.PARENT_TASKS || []).filter(t => String(t.phaseId) === String(phaseFilter)); const filtersActive = phaseFilter !== 'all' || taskFilter !== 'all' || !!query; const changeStatus = async (item, status) => { setStatusFor(null); if (item.status === status) return; setBusyId(item.id); try { await window.api.procurement.status(item.id, status); await window.refreshData(); toast('✓ ' + window.procStatusLabel(status)); } catch (err) { alert(window.apiErrorMessage(err)); } finally { setBusyId(null); } }; return (
Projekt

Beszerzés

{canEdit && ( )}
{/* Összesítő — a szűrt listára. Az állapot-cellák egyben szűrők is. */}
Beszerzési lista értéke
{window.formatHufShort(grandTotal)} Ft
{`${filtered.length} tétel`}
{statusFilter !== 'all' && ( )}
{/* Keskeny kijelzőn 2×2-be törik, szélesen egy sorba rendeződik. */}
{statTotals.map(s => { const color = window.PROC_STATUS_COLORS[s.status]; const active = statusFilter === s.status; return ( ); })}
{/* Szűrők — keresés mindenben, fázis és költségvetési tétel szerint. */} { setQuery(''); setPhaseFilter('all'); setTaskFilter('all'); }}>
setQuery(e.target.value)} placeholder="Keresés név, bolt, leírás, fázis, ár…" autoComplete="off" spellCheck={false}/> {query && ( )}
{/* Lista — fázisonként csoportosítva, a keret melletti összesítéssel. */}
{groups.map(g => { const totals = window.procPhaseTotals(g.phase.id); const budget = window.phaseBudget(g.phase.id); const groupSum = sumOf(g.items); return (
{g.phase.code} {g.phase.name}
{`${g.items.length} tétel · tervezett ${window.formatHufShort(totals.planned)} Ft` + ` · megvásárolt ${window.formatHufShort(totals.bought)} Ft`}
{window.formatHufShort(groupSum)} Ft
{budget > 0 && (
{`/ ${window.formatHufShort(budget)} Ft keret`}
)}
{g.items.map(it => ( setSheet({ item: x })} onStatus={(x) => setStatusFor(x)}/> ))}
); })} {groups.length === 0 && (
{items.length === 0 ? 'Még nincs beszerzési tétel. Vedd fel az elsőt kézzel, vagy illeszd be a termék linkjét.' : 'Nincs a szűrőnek megfelelő beszerzési tétel.'}
)}
{/* Gyors állapotváltás a listából. */} {statusFor && ( setStatusFor(null)} title="Állapot módosítása">
{window.procName(statusFor)}
{window.PROC_STATUSES.map(s => { const c = window.PROC_STATUS_COLORS[s]; const active = statusFor.status === s; return ( ); })}
)} {sheet && ( setSheet(null)} onDone={(msg) => { setSheet(null); if (msg) toast(msg); }}/> )}
); }; // ─── rögzítő / szerkesztő űrlap ──────────────────────────────────────────── const ProcurementSheet = ({ item, user, onClose, onDone }) => { const isNew = !item; const canEdit = !!(window.hasPerm && window.hasPerm('procurement.edit', user, true)); const isManager = user && (user.role === 'admin' || user.role === 'approver'); const canDelete = !isNew && canEdit && (isManager || item.createdBy === user.id); // A felhasználó a SAJÁT nyelvén ír; a másik nyelvű változatot a szerver // készíti el mentéskor, ezért itt csak egy megnevezés/leírás mező van. // A `other*` állapot a linkes beolvasásból már meglévő másik nyelvű szöveget // viszi tovább (ilyenkor a szervernek nem kell újrafordítania). const lang = window.LANG === 'en' ? 'en' : 'hu'; const [link, setLink] = React.useState(item?.url || ''); const [name, setName] = React.useState(item ? window.procName(item) : ''); const [desc, setDesc] = React.useState(item ? window.procDescription(item) : ''); const [otherName, setOtherName] = React.useState(''); const [otherDesc, setOtherDesc] = React.useState(''); const [vendor, setVendor] = React.useState(item?.vendor || ''); const [imageUrl, setImageUrl] = React.useState(item?.imageUrl || ''); const [phaseId, setPhaseId] = React.useState(item ? String(item.phaseId) : ''); const [taskId, setTaskId] = React.useState(item?.parentTaskId ? String(item.parentTaskId) : ''); const [price, setPrice] = React.useState(item ? String(item.unitPrice) : ''); const [currency, setCurrency] = React.useState(item?.currency || 'HUF'); const [qty, setQty] = React.useState(item ? String(item.quantity) : '1'); const [unit, setUnit] = React.useState(item?.unit || 'db'); const [status, setStatus] = React.useState(item?.status || 'shortlisted'); const [note, setNote] = React.useState(item?.note || ''); const [fetching, setFetching] = React.useState(false); const [fetchNote, setFetchNote] = React.useState(''); // Igaz, ha a fázist/tételt a beolvasás töltötte ki — ilyenkor jelezzük, hogy // ez javaslat, és nyugodtan átállítható. const [suggested, setSuggested] = React.useState(false); const [busy, setBusy] = React.useState(false); const [dirty, setDirty] = React.useState(false); const touch = () => setDirty(true); // Lezárt fázisba csak admin/jóváhagyó vehet fel új tételt; a MÁR ott lévő // tétel fázisa viszont mindig szerepel a listában, hogy szerkesztéskor ne // essen ki a kiválasztott érték. const phases = (window.PHASES || []).filter(p => !p.locked || isManager || (item && item.phaseId === p.id)); const tasks = phaseId ? (window.PARENT_TASKS || []).filter(t => String(t.phaseId) === String(phaseId)) : []; const fx = window.FX_RATES || { HUF: 1 }; const priceNum = parseFloat(String(price).replace(',', '.')) || 0; const qtyNum = parseFloat(String(qty).replace(',', '.')) || 0; const unitHuf = Math.round(priceNum * (fx[currency] || 1)); const totalHuf = Math.round(unitHuf * qtyNum); // Link-beolvasás: a szerver kiolvassa a termékadatokat, mi csak előtöltjük az // űrlapot. A már kitöltött mezőket nem írjuk felül vakon — a név/ár/kép igen, // hiszen épp azért kérte a felhasználó. const fetchLink = async () => { const url = link.trim(); if (!url) return; setFetching(true); setFetchNote(''); try { const res = await window.api.procurement.fetchLink(url); const p = res.product || {}; // A saját nyelvünkön töltjük ki a mezőt; a másik nyelvű változat (ha a // beolvasás adott ilyet) rejtve utazik tovább a mentésig. const mine = lang === 'en' ? (p.nameEn || p.name) : p.name; const theirs = lang === 'en' ? p.name : p.nameEn; const mineDesc = lang === 'en' ? (p.descriptionEn || p.description) : p.description; const theirsDesc = lang === 'en' ? p.description : p.descriptionEn; if (mine) setName(mine); if (mineDesc) setDesc(mineDesc); setOtherName(theirs && theirs !== mine ? theirs : ''); setOtherDesc(theirsDesc && theirsDesc !== mineDesc ? theirsDesc : ''); if (p.vendor) setVendor(p.vendor); if (p.imageUrl) setImageUrl(p.imageUrl); if (p.url) setLink(p.url); if (p.unit) setUnit(p.unit); if (p.price > 0) { setPrice(String(p.price)); setCurrency(p.currency || 'HUF'); } // Fázis + költségvetési tétel a termék alapján. Csak az üres mezőket // töltjük ki: ha a felhasználó már választott, az ő döntése marad. const s = res.suggestion || {}; let applied = false; if (!phaseId && s.phaseId) { setPhaseId(String(s.phaseId)); applied = true; if (s.parentTaskId) setTaskId(String(s.parentTaskId)); } else if (phaseId && !taskId && s.parentTaskId && String(s.phaseId) === String(phaseId)) { setTaskId(String(s.parentTaskId)); applied = true; } setSuggested(applied); touch(); setFetchNote( p.price > 0 ? (res.aiUsed ? '✓ Beolvasva (AI kiegészítéssel)' : '✓ Beolvasva') : '✓ Beolvasva — az árat nem találtam, írd be kézzel' ); } catch (err) { setFetchNote('Nem sikerült: ' + window.apiErrorMessage(err)); } finally { setFetching(false); } }; const save = async () => { if (!name.trim()) { alert(window.t('Adj nevet a tételnek')); return; } if (!phaseId) { alert(window.t('Válassz fázist')); return; } setBusy(true); try { await window.api.procurement.save({ id: item ? item.id : 0, phase_id: parseInt(phaseId, 10), parent_task_id: taskId ? parseInt(taskId, 10) : null, name: name.trim(), description: desc.trim(), source_lang: lang, name_other: otherName.trim(), description_other: otherDesc.trim(), vendor: vendor.trim(), url: link.trim(), image_url: imageUrl.trim(), price: priceNum, currency, quantity: qtyNum || 1, unit: unit.trim(), status, note: note.trim(), }); await window.refreshData(); onDone(isNew ? '✓ Beszerzési tétel felvéve' : '✓ Módosítások mentve'); } catch (err) { alert(window.apiErrorMessage(err)); setBusy(false); } }; const remove = async () => { if (!window.confirm(window.t('Biztosan törlöd ezt a beszerzési tételt?') + '\n\n' + window.procName(item))) return; setBusy(true); try { await window.api.procurement.del(item.id); await window.refreshData(); onDone('✓ Tétel törölve'); } catch (err) { alert(window.apiErrorMessage(err)); setBusy(false); } }; // Keret-környezet a kiválasztott költségvetési tételhez: mennyi a keret, // mennyi ment el jóváhagyott teljesítésre, és mennyi van a beszerzési listán. const task = taskId ? window.getParentTask(parseInt(taskId, 10)) : null; const taskSpent = task ? window.parentTaskSpent(task.id) : 0; const taskProc = task ? window.procTaskTotals(task.id) : null; return ( {/* Link-beolvasás — a leggyorsabb út egy webáruházi termék felvételéhez. */} {canEdit && (
{ setLink(e.target.value); touch(); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); fetchLink(); } }} placeholder="https://…" autoComplete="off" spellCheck={false} inputMode="url"/>
{fetchNote || 'Illeszd be a termék linkjét — a név, a leírás, az ár és a kép magától kitöltődik.'}
)} {/* Termékkép — a beolvasott előnézet, elvethető. */} {imageUrl && (
{imageUrl}
)} {/* Megnevezés + leírás a felhasználó nyelvén. A másik nyelvű változat mentéskor, automatikusan készül el — nincs külön mezője. */}
{ setName(e.target.value); setOtherName(''); touch(); }} placeholder="Pl. Grohe zuhanyszett" autoComplete="off"/>