Festpreis-Input-Fix, Archiv-Teilen (read-only)

This commit is contained in:
2026-05-28 13:30:39 +02:00
parent 188f121cda
commit 83e88745fa
4 changed files with 165 additions and 25 deletions

View File

@@ -246,4 +246,18 @@ if (msgCols2.length && !msgCols2.includes('read_by_recipient')) {
db.exec("ALTER TABLE messages ADD COLUMN read_by_recipient INTEGER NOT NULL DEFAULT 0"); db.exec("ALTER TABLE messages ADD COLUMN read_by_recipient INTEGER NOT NULL DEFAULT 0");
} }
// ── Kalkulator-Shares ─────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calculation_shares'").get()) {
db.exec(`
CREATE TABLE calculation_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
calc_id INTEGER NOT NULL REFERENCES calculations(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT NULL,
UNIQUE(calc_id, shared_with)
)
`);
}
module.exports = db; module.exports = db;

View File

@@ -8,24 +8,62 @@ router.get('/', authenticate, (req, res) => {
const uid = req.user.id; const uid = req.user.id;
const items = db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(uid); const items = db.prepare('SELECT * FROM calculations WHERE user_id=? ORDER BY created_at DESC').all(uid);
// Prüfen welche Modelle Dateien haben: Unterordner von "3D-Modelle" mit Dateien
const baseFolder = db.prepare( const baseFolder = db.prepare(
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL" "SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
).get(uid); ).get(uid);
let filesMap = {}; let filesMap = {};
if (baseFolder) { if (baseFolder) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT f.name, COUNT(fi.id) AS cnt SELECT f.name, COUNT(fi.id) AS cnt FROM folders f
FROM folders f
LEFT JOIN files fi ON fi.folder_id = f.id LEFT JOIN files fi ON fi.folder_id = f.id
WHERE f.parent_id = ? AND f.user_id = ? WHERE f.parent_id = ? AND f.user_id = ? GROUP BY f.id
GROUP BY f.id
`).all(baseFolder.id, uid); `).all(baseFolder.id, uid);
for (const r of rows) filesMap[r.name] = r.cnt > 0; for (const r of rows) filesMap[r.name] = r.cnt > 0;
} }
res.json(items.map(i => ({ ...i, has_files: !!filesMap[i.name] }))); // Geteilt mit mir
const shared = db.prepare(`
SELECT c.*, u.username as owner_name, 1 as is_shared
FROM calculations c
JOIN calculation_shares cs ON cs.calc_id = c.id
JOIN users u ON u.id = c.user_id
WHERE cs.shared_with = ?
ORDER BY c.created_at DESC
`).all(uid);
res.json({
own: items.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
shared: shared,
});
});
// Share-Management
router.get('/:id/shares', authenticate, (req, res) => {
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(db.prepare(`
SELECT u.id, u.username, cs.created_at as shared_at
FROM calculation_shares cs JOIN users u ON u.id = cs.shared_with
WHERE cs.calc_id = ?
`).all(calc.id));
});
router.post('/:id/share', authenticate, (req, res) => {
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
db.prepare("INSERT OR IGNORE INTO calculation_shares (calc_id, shared_by, shared_with, created_at) VALUES (?,?,?,datetime('now','localtime'))")
.run(calc.id, req.user.id, target.id);
res.json({ ok: true });
});
router.delete('/:id/share/:userId', authenticate, (req, res) => {
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM calculation_shares WHERE calc_id=? AND shared_with=?').run(calc.id, req.params.userId);
res.json({ ok: true });
}); });
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {

View File

@@ -284,6 +284,7 @@ function BestellungDetail({ id, toast, onBack }) {
<div style={{...S.head,marginBottom:4}}>FESTPREIS/STK ()</div> <div style={{...S.head,marginBottom:4}}>FESTPREIS/STK ()</div>
<div style={{display:'flex',gap:6,alignItems:'stretch'}}> <div style={{display:'flex',gap:6,alignItems:'stretch'}}>
<input type="number" step="0.01" min="0" <input type="number" step="0.01" min="0"
key={item.custom_price ?? 'none'}
defaultValue={item.custom_price??''} defaultValue={item.custom_price??''}
placeholder="" placeholder=""
readOnly={!!order.bezahlt} readOnly={!!order.bezahlt}

View File

@@ -541,48 +541,126 @@ export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setA
} }
// ── Archiv ────────────────────────────────────────────────────────────── // ── Archiv ──────────────────────────────────────────────────────────────
// ── Share Modal ───────────────────────────────────────────────────────────────
function CalcShareModal({ item, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/kalkulator3d/${item.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/kalkulator3d/${item.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e) { toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/kalkulator3d/${item.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); toast('Freigabe entfernt'); }
catch(e) { toast(e.message,'error'); }
};
const isMobile = window.innerWidth<768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:6000,
display:'flex',alignItems:isMobile?'flex-end':'center',justifyContent:'center',padding:isMobile?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMobile?'16px 16px 0 0':16,
width:'100%',maxWidth:420,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
🖨 {item.name}
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,padding:'0 4px'}}></button>
</div>
<p style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
Empfänger können das Modell nur ansehen, nicht bearbeiten oder löschen.
</p>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1,fontSize:15}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? (
<div>
<div style={{...S.head,marginBottom:6}}>GETEILT MIT</div>
{shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<div>
<div style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:13}}>{s.username}</div>
{s.shared_at && <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>seit {new Date(s.shared_at.replace(' ','T')).toLocaleDateString('de-DE')}</div>}
</div>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
))}
</div>
) : (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch mit niemandem geteilt.</div>
)}
</div>
</div>
);
}
export function SavedList({ toast, mobile }) { export function SavedList({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
const [items, setItems] = useState([]); const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [delId, setDelId] = useState(null); const [tab, setTab] = useState('own');
const [editData, setEditData] = useState(null); const [editData, setEditData] = useState(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [lightbox, setLightbox] = useState(null); const [lightbox, setLightbox] = useState(null);
const [shareItem,setShareItem]= useState(null);
useEffect(() => { const load = () => {
api('/tools/kalkulator3d').then(setItems).catch(e => toast(e.message, 'error')).finally(() => setLoading(false)); setLoading(true);
}, []); api('/tools/kalkulator3d')
.then(d => { setOwn(d.own||[]); setShared(d.shared||[]); })
.catch(e => toast(e.message,'error'))
.finally(()=>setLoading(false));
};
useEffect(()=>{ load(); },[]);
const del = async id => { const del = async id => {
try { await api(`/tools/kalkulator3d/${id}`, { method:'DELETE' }); setItems(p => p.filter(i => i.id !== id)); toast('Gelöscht'); } try { await api(`/tools/kalkulator3d/${id}`,{method:'DELETE'}); setOwn(p=>p.filter(i=>i.id!==id)); toast('Gelöscht'); }
catch(e) { toast(e.message, 'error'); } finally { setDelId(null); } catch(e) { toast(e.message,'error'); }
}; };
const items = tab==='own' ? own : shared;
const filtered = search.trim() const filtered = search.trim()
? items.filter(i => ? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase())))
i.name.toLowerCase().includes(search.toLowerCase()) ||
(i.bemerkung && i.bemerkung.toLowerCase().includes(search.toLowerCase()))
)
: items; : items;
if (editData) return ( if (editData) return (
<Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => { <Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => { setEditData(null); load(); }} />
setEditData(null);
api('/tools/kalkulator3d').then(setItems).catch(() => {});
}} />
); );
return ( return (
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}> <div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}>
{lightbox && <ImageModal src={lightbox} onClose={() => setLightbox(null)} />} {lightbox && <ImageModal src={lightbox} onClose={() => setLightbox(null)} />}
{shareItem && <CalcShareModal item={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
<ConfirmDialog/>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:14 }}> <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:14 }}>
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Archiv</h1> <h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?18:22, margin:0 }}>Archiv</h1>
<button onClick={() => api('/tools/kalkulator3d').then(setItems).catch(()=>{})} title="Neu laden" <button onClick={load} title="Neu laden"
style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8, style={{ background:'transparent', border:'1px solid rgba(255,255,255,0.1)', borderRadius:8,
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button> color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}></button>
</div> </div>
{/* Tabs */}
<div style={{ display:'flex', gap:6, marginBottom:14 }}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{
padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer',
background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)',
color: tab===k ? '#0d0d0f' : 'rgba(255,255,255,0.55)',
border: tab===k ? 'none' : '1px solid rgba(255,255,255,0.1)',
fontWeight: tab===k ? 700 : 400,
}}>
{l}{cnt>0&&<span style={{marginLeft:5,opacity:0.7}}>({cnt})</span>}
</button>
))}
</div>
{/* Suche */} {/* Suche */}
<div style={{ position:'relative', marginBottom:12 }}> <div style={{ position:'relative', marginBottom:12 }}>
<span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)', <span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)',
@@ -674,8 +752,17 @@ export function SavedList({ toast, mobile }) {
{/* Aktionen */} {/* Aktionen */}
<div style={{ display:'flex', gap:6, marginTop:10 }}> <div style={{ display:'flex', gap:6, marginTop:10 }}>
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}> Bearbeiten</button> {!item.is_shared ? (
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), flex:1, textAlign:'center' }}> Löschen</button> <>
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}> Bearbeiten</button>
<button onClick={() => setShareItem(item)} style={{ ...S.btn('#ffe66d', true), padding:'0 12px' }} title="Teilen">🤝</button>
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), padding:'0 12px' }} title="Löschen"></button>
</>
) : (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}>
von {item.owner_name} · nur Ansicht
</div>
)}
</div> </div>
</div> </div>
))} ))}