Festpreis-Input-Fix, Archiv-Teilen (read-only)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -8,24 +8,62 @@ router.get('/', authenticate, (req, res) => {
|
||||
const uid = req.user.id;
|
||||
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(
|
||||
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
|
||||
).get(uid);
|
||||
|
||||
let filesMap = {};
|
||||
if (baseFolder) {
|
||||
const rows = db.prepare(`
|
||||
SELECT f.name, COUNT(fi.id) AS cnt
|
||||
FROM folders f
|
||||
SELECT f.name, COUNT(fi.id) AS cnt FROM folders f
|
||||
LEFT JOIN files fi ON fi.folder_id = f.id
|
||||
WHERE f.parent_id = ? AND f.user_id = ?
|
||||
GROUP BY f.id
|
||||
WHERE f.parent_id = ? AND f.user_id = ? GROUP BY f.id
|
||||
`).all(baseFolder.id, uid);
|
||||
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) => {
|
||||
|
||||
@@ -284,6 +284,7 @@ function BestellungDetail({ id, toast, onBack }) {
|
||||
<div style={{...S.head,marginBottom:4}}>FESTPREIS/STK (€)</div>
|
||||
<div style={{display:'flex',gap:6,alignItems:'stretch'}}>
|
||||
<input type="number" step="0.01" min="0"
|
||||
key={item.custom_price ?? 'none'}
|
||||
defaultValue={item.custom_price??''}
|
||||
placeholder="–"
|
||||
readOnly={!!order.bezahlt}
|
||||
|
||||
@@ -541,48 +541,126 @@ export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setA
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const [items, setItems] = useState([]);
|
||||
const [own, setOwn] = useState([]);
|
||||
const [shared, setShared] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [delId, setDelId] = useState(null);
|
||||
const [tab, setTab] = useState('own');
|
||||
const [editData, setEditData] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [lightbox, setLightbox] = useState(null);
|
||||
const [shareItem,setShareItem]= useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api('/tools/kalkulator3d').then(setItems).catch(e => toast(e.message, 'error')).finally(() => setLoading(false));
|
||||
}, []);
|
||||
const load = () => {
|
||||
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 => {
|
||||
try { await api(`/tools/kalkulator3d/${id}`, { method:'DELETE' }); setItems(p => p.filter(i => i.id !== id)); toast('Gelöscht'); }
|
||||
catch(e) { toast(e.message, 'error'); } finally { setDelId(null); }
|
||||
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'); }
|
||||
};
|
||||
|
||||
const items = tab==='own' ? own : shared;
|
||||
const filtered = search.trim()
|
||||
? items.filter(i =>
|
||||
i.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(i.bemerkung && i.bemerkung.toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase())))
|
||||
: items;
|
||||
|
||||
if (editData) return (
|
||||
<Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => {
|
||||
setEditData(null);
|
||||
api('/tools/kalkulator3d').then(setItems).catch(() => {});
|
||||
}} />
|
||||
<Kalkulator3D toast={toast} mobile={mobile} editData={editData} onEditDone={() => { setEditData(null); load(); }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: mobile ? '14px 14px 90px' : '36px 44px', maxWidth:900 }}>
|
||||
{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 }}>
|
||||
<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,
|
||||
color:'rgba(255,255,255,0.4)', cursor:'pointer', padding:'6px 10px', fontSize:14, fontFamily:'monospace' }}>↺</button>
|
||||
</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 */}
|
||||
<div style={{ position:'relative', marginBottom:12 }}>
|
||||
<span style={{ position:'absolute', left:12, top:'50%', transform:'translateY(-50%)',
|
||||
@@ -674,8 +752,17 @@ export function SavedList({ toast, mobile }) {
|
||||
|
||||
{/* Aktionen */}
|
||||
<div style={{ display:'flex', gap:6, marginTop:10 }}>
|
||||
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>✎ Bearbeiten</button>
|
||||
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), flex:1, textAlign:'center' }}>✕ Löschen</button>
|
||||
{!item.is_shared ? (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user