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)}
)}
);
};
// ─── 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 (
{canEdit && (
setSheet({ item: null })}>
Új tétel
)}
{/* Ö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' && (
setStatusFilter('all')} style={{ whiteSpace: 'nowrap' }}>
Szűrő törlése
)}
{/* 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 (
setStatusFilter(active ? 'all' : s.status)}
style={{
textAlign: 'left', padding: '8px 9px', borderRadius: 'var(--r-sm)',
background: active ? color + '1F' : 'transparent',
border: '1px solid ' + (active ? color + '55' : 'var(--line-2)'),
cursor: 'pointer', minWidth: 0,
}}>
{window.procStatusLabel(s.status)}
{window.formatHufShort(s.sum)} Ft
{`${s.count} tétel`}
);
})}
{/* 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 && (
setQuery('')} aria-label="Keresés törlése">
)}
Fázis
{ setPhaseFilter(e.target.value); setTaskFilter('all'); }}>
Mind
{(window.PHASES || []).map(p => {p.name} )}
Költségvetési tétel
setTaskFilter(e.target.value)}>
Mind
{tasksForFilter.map(t => {t.name} )}
{/* 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 (
changeStatus(statusFor, s)}
style={{ background: active ? c : c + '22', color: active ? '#fff' : c, border: active ? '1px solid ' + c : 'none', cursor: 'pointer' }}>
{window.procStatusLabel(s)}{active ? ' ✓' : ''}
);
})}
)}
{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 && (
)}
{/* Termékkép — a beolvasott előnézet, elvethető. */}
{imageUrl && (
{imageUrl}
{ setImageUrl(''); touch(); }} aria-label="Kép eltávolítása">
)}
{/* 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. */}
Megnevezés
{ setName(e.target.value); setOtherName(''); touch(); }}
placeholder="Pl. Grohe zuhanyszett" autoComplete="off"/>
Leírás
{/* Költségvetési hovatartozás — enélkül a tétel nem köthető kerethez.
Linkes rögzítésnél a termék alapján előre kitöltjük. */}
Fázis
{ setPhaseId(e.target.value); setTaskId(''); setSuggested(false); touch(); }}>
Válassz fázist…
{phases.map(p => {p.code} · {p.name} )}
{suggested && (
A termék alapján javasolva — átállíthatod.
)}
Költségvetési tétel
{ setTaskId(e.target.value); setSuggested(false); touch(); }}
disabled={!phaseId}>
Nincs megadva
{tasks.map(t => {t.name} )}
{!taskId && phaseId && (
Költségvetési tétel nélkül a beszerzés csak a fázis szintjén jelenik meg.
)}
{/* Keret-környezet: hol tart ez a költségvetési tétel. */}
{task && (
Keret
{window.formatHuf(task.budget)}
task.budget}/>
Elköltve (jóváhagyott)
{window.formatHuf(taskSpent)}
Beszerzési listán
{`${window.formatHufShort(taskProc.planned)} Ft tervezett · ${window.formatHufShort(taskProc.bought)} Ft megvásárolt`}
)}
{/* Ár, pénznem, mennyiség. */}
Összesen
{window.formatHuf(totalHuf)}
{currency !== 'HUF' && (
{`Árfolyam: 1 ${currency} = ${window.formatHuf(fx[currency] || 0)}`}
)}
Bolt / gyártó
{ setVendor(e.target.value); touch(); }}
placeholder="Pl. Praktiker" autoComplete="off"/>
Állapot
{window.PROC_STATUSES.map(s => {
const c = window.PROC_STATUS_COLORS[s];
const active = status === s;
return (
{ setStatus(s); touch(); }}
style={{ background: active ? c : c + '22', color: active ? '#fff' : c, border: active ? '1px solid ' + c : 'none', cursor: 'pointer' }}>
{window.procStatusLabel(s)}
);
})}
Megjegyzés
{!isNew && (
Rögzítette:
{`${(window.getUser(item.createdBy) || {}).name || '—'} · ${window.formatDate(item.createdAt)}`}
{item.purchasedAt && {` · megvásárolva ${window.formatDate(item.purchasedAt)}`} }
{item.installedAt && {` · beépítve ${window.formatDate(item.installedAt)}`} }
)}
Mégse
{canEdit && (
{busy ? 'Mentés…' : 'Mentés'}
)}
{canDelete && (
Tétel törlése
)}
);
};
Object.assign(window, { Procurement, ProcurementSheet, ProcStatusPill });