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");
|
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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
{!item.is_shared ? (
|
||||||
|
<>
|
||||||
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>✎ Bearbeiten</button>
|
<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>
|
<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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user