Compare commits
3 Commits
070ea75369
...
845b634804
| Author | SHA1 | Date | |
|---|---|---|---|
| 845b634804 | |||
| 8fabf7bb30 | |||
| b18dfbe3f8 |
@@ -150,15 +150,6 @@ export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
|
|||||||
{/* Liste-Tab: vollständige Liste */}
|
{/* Liste-Tab: vollständige Liste */}
|
||||||
{activeTab === 'liste' && (
|
{activeTab === 'liste' && (
|
||||||
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
|
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
|
||||||
<div className="flex justify-between items-center mb-2 print:hidden">
|
|
||||||
<span className="text-sm font-semibold text-gray-600">Einträge {activeKuppel}-Kuppel</span>
|
|
||||||
<button
|
|
||||||
onClick={() => window.print()}
|
|
||||||
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
|
|
||||||
>
|
|
||||||
🖨 Drucken
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden print:block mb-4">
|
<div className="hidden print:block mb-4">
|
||||||
<div className="text-lg font-bold">Sternwarte Welzheim — Logbuch {activeKuppel}-Kuppel</div>
|
<div className="text-lg font-bold">Sternwarte Welzheim — Logbuch {activeKuppel}-Kuppel</div>
|
||||||
<div className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
|
<div className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ const LIST_SQL =
|
|||||||
' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' +
|
' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' +
|
||||||
' LEFT JOIN objekte o ON o.ID = lo.ObjektID' +
|
' LEFT JOIN objekte o ON o.ID = lo.ObjektID' +
|
||||||
' WHERE l.Kuppel = ?' +
|
' WHERE l.Kuppel = ?' +
|
||||||
' GROUP BY l.ID' +
|
' GROUP BY l.ID';
|
||||||
' ORDER BY l.Beginn DESC';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const kuppel = searchParams.get('kuppel') || 'West';
|
const kuppel = searchParams.get('kuppel') || 'West';
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 100);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '10') || 10, 500);
|
||||||
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0);
|
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0);
|
||||||
const month = searchParams.get('month') || '';
|
const month = searchParams.get('month') || '';
|
||||||
|
const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
let listWhere = 'WHERE l.Kuppel = ?';
|
let listWhere = 'WHERE l.Kuppel = ?';
|
||||||
let countWhere = 'WHERE Kuppel = ?';
|
let countWhere = 'WHERE Kuppel = ?';
|
||||||
@@ -50,7 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const [countRows, entries] = await Promise.all([
|
const [countRows, entries] = await Promise.all([
|
||||||
query('SELECT COUNT(*) AS total FROM logbuch ' + countWhere, params) as Promise<{ total: number }[]>,
|
query('SELECT COUNT(*) AS total FROM logbuch ' + countWhere, params) as Promise<{ total: number }[]>,
|
||||||
query(LIST_SQL.replace('WHERE l.Kuppel = ?', listWhere) + ` LIMIT ${limit} OFFSET ${offset}`, params),
|
query(LIST_SQL.replace('WHERE l.Kuppel = ?', listWhere) + ` ORDER BY l.Beginn ${order} LIMIT ${limit} OFFSET ${offset}`, params),
|
||||||
]);
|
]);
|
||||||
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
|
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -33,4 +33,8 @@ body {
|
|||||||
body {
|
body {
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
table {
|
||||||
|
font-size: 0.72rem !important;
|
||||||
|
width: 95% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
|
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,6 +58,8 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null);
|
||||||
|
const printPending = useRef(false);
|
||||||
|
|
||||||
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month]);
|
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month]);
|
||||||
|
|
||||||
@@ -72,6 +74,27 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
|
|||||||
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
|
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
|
||||||
}, [kuppel, refreshKey, limit, page, month]);
|
}, [kuppel, refreshKey, limit, page, month]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onAfterPrint() { setPrintEntries(null); }
|
||||||
|
window.addEventListener('afterprint', onAfterPrint);
|
||||||
|
return () => window.removeEventListener('afterprint', onAfterPrint);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (printPending.current && printEntries !== null) {
|
||||||
|
printPending.current = false;
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}, [printEntries]);
|
||||||
|
|
||||||
|
async function handlePrint() {
|
||||||
|
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=500&offset=0` +
|
||||||
|
(month ? `&month=${encodeURIComponent(month)}` : '') + '&order=asc';
|
||||||
|
const data = await fetch(url).then((r) => r.json());
|
||||||
|
printPending.current = true;
|
||||||
|
setPrintEntries(data.entries);
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDelete(id: number) {
|
async function confirmDelete(id: number) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' });
|
const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' });
|
||||||
@@ -129,8 +152,21 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
|
|||||||
if (loading) return <>{monthNav}<div className="text-gray-500 text-sm py-4">Lade Einträge...</div></>;
|
if (loading) return <>{monthNav}<div className="text-gray-500 text-sm py-4">Lade Einträge...</div></>;
|
||||||
if (error) return <>{monthNav}<div className="text-red-600 text-sm py-4">{error}</div></>;
|
if (error) return <>{monthNav}<div className="text-red-600 text-sm py-4">{error}</div></>;
|
||||||
|
|
||||||
|
const displayEntries = printEntries ?? entries;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{!compact && (
|
||||||
|
<div className="flex justify-between items-center mb-2 print:hidden">
|
||||||
|
<span className="text-sm font-semibold text-gray-600">Einträge {kuppel}-Kuppel</span>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
🖨 Drucken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{monthNav}
|
{monthNav}
|
||||||
{printHeader}
|
{printHeader}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -156,13 +192,13 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.length === 0 ? (
|
{displayEntries.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center">
|
<td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center">
|
||||||
Keine Einträge für {monthLabel(month)}.
|
Keine Einträge für {monthLabel(month)}.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : entries.map((e) => (
|
) : displayEntries.map((e) => (
|
||||||
<tr key={e.ID} className="hover:bg-gray-50">
|
<tr key={e.ID} className="hover:bg-gray-50">
|
||||||
<td className={`${cell} whitespace-nowrap`}>{formatDate(e.Beginn, compact)}</td>
|
<td className={`${cell} whitespace-nowrap`}>{formatDate(e.Beginn, compact)}</td>
|
||||||
{compact ? (
|
{compact ? (
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
const [all, setAll] = useState<ObjektOption[]>([]);
|
const [all, setAll] = useState<ObjektOption[]>([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
|
||||||
const [showNewInput, setShowNewInput] = useState(false);
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,29 +37,44 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
|
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
|
||||||
: available;
|
: available;
|
||||||
|
|
||||||
|
const searchTrimmed = search.trim();
|
||||||
|
const alreadySelected = searchTrimmed !== '' && selectedNames.has(searchTrimmed.toLowerCase());
|
||||||
|
const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase());
|
||||||
|
const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch;
|
||||||
|
|
||||||
function add(obj: ObjektOption) {
|
function add(obj: ObjektOption) {
|
||||||
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
|
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNew() {
|
function addNew(name: string) {
|
||||||
const name = newName.trim();
|
const trimmed = name.trim();
|
||||||
if (!name || selectedNames.has(name.toLowerCase())) return;
|
if (!trimmed || selectedNames.has(trimmed.toLowerCase())) return;
|
||||||
const existing = all.find((o) => o.Name.toLowerCase() === name.toLowerCase());
|
const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase());
|
||||||
if (existing) {
|
if (existing) {
|
||||||
onChange([...selected, { ID: existing.ID, Name: existing.Name }]);
|
onChange([...selected, { ID: existing.ID, Name: existing.Name }]);
|
||||||
} else {
|
} else {
|
||||||
onChange([...selected, { ID: null, Name: name }]);
|
onChange([...selected, { ID: null, Name: trimmed }]);
|
||||||
}
|
}
|
||||||
setNewName('');
|
setSearch('');
|
||||||
setShowNewInput(false);
|
setDropdownOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(name: string) {
|
function remove(name: string) {
|
||||||
onChange(selected.filter((o) => o.Name !== name));
|
onChange(selected.filter((o) => o.Name !== name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered.length === 1 && !showAddNew) {
|
||||||
|
add(filtered[0]);
|
||||||
|
} else if (filtered.length === 0 && searchTrimmed) {
|
||||||
|
addNew(searchTrimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -83,69 +96,40 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div ref={wrapperRef} className="relative">
|
||||||
{available.length > 0 && (
|
<input
|
||||||
<div ref={wrapperRef} className="relative flex-1">
|
type="text"
|
||||||
<input
|
value={search}
|
||||||
type="text"
|
onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }}
|
||||||
value={search}
|
onFocus={() => setDropdownOpen(true)}
|
||||||
onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setDropdownOpen(true)}
|
placeholder="Objekt suchen oder neu eingeben..."
|
||||||
placeholder="Objekt suchen..."
|
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
|
||||||
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
|
/>
|
||||||
/>
|
{dropdownOpen && (filtered.length > 0 || showAddNew) && (
|
||||||
{dropdownOpen && filtered.length > 0 && (
|
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
{filtered.map((o) => (
|
||||||
{filtered.map((o) => (
|
<button
|
||||||
<button
|
key={o.ID}
|
||||||
key={o.ID}
|
type="button"
|
||||||
type="button"
|
onClick={() => add(o)}
|
||||||
onClick={() => add(o)}
|
className="w-full text-left px-4 py-2 text-sm text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0"
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0"
|
>
|
||||||
>
|
{o.Name}
|
||||||
{o.Name}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
{showAddNew && (
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addNew(searchTrimmed)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 active:bg-blue-100 border-t border-gray-200 font-medium"
|
||||||
|
>
|
||||||
|
+ „{searchTrimmed}" hinzufügen
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewInput((v) => !v)}
|
|
||||||
className="px-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
+ Neu
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewInput && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
|
|
||||||
placeholder="Objektname eingeben"
|
|
||||||
className="flex-1 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addNew}
|
|
||||||
className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700"
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setShowNewInput(false); setNewName(''); }}
|
|
||||||
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
@@ -3298,6 +3298,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "logbuch",
|
"name": "logbuch",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user