e70ae79d-5898-4c55-8ceb-d2c701ce08b4

This commit is contained in:
Z User committed 2026-10-26 14:18:39 +00:00
1 parent d226dabba3
commit 5855f699f3
53 files changed
+1910 -43

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Verify the new AOS features: audit view, company drawer, lead convert, invoice dialog, create deal, search, task/time logging.
set -u
cd /home/z/my-project
BASE=http://127.0.0.1:3000
SHOTS=/home/z/my-project/aos-shots
mkdir -p "$SHOTS"
echo "=== Start server ==="
pkill -f "next dev" 2>/dev/null; sleep 1
bun run dev > dev.log 2>&1 &
SRV=$!
for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done
echo " ready after ${i}s"; sleep 2
echo ""
echo "=== New API endpoints ==="
for ep in "search?q=hamburg" "audit?limit=20"; do
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/aos/$ep")
echo " /api/aos/$ep -> $code"
done
echo ""
echo "=== Test lead convert API (pick first pending lead) ==="
LEADS=$(curl -s "$BASE/api/aos/leads")
# find a pending lead id via python
LEAD_ID=$(echo "$LEADS" | python3 -c "import sys,json; d=json.load(sys.stdin); print(next((l['id'] for l in d['leads'] if l['status']=='pending'), d['leads'][0]['id']))" 2>/dev/null)
echo " lead id: $LEAD_ID"
# first approve it
curl -s -X PATCH "$BASE/api/aos/leads/$LEAD_ID" -H 'content-type: application/json' -d '{"status":"approved","reviewReason":"Test approval"}' >/dev/null
echo " approved."
# then convert
CONV=$(curl -s -X POST "$BASE/api/aos/leads/convert" -H 'content-type: application/json' -d "{\"leadId\":\"$LEAD_ID\"}")
echo " convert response (first 200 chars): $(echo "$CONV" | head -c 200)"
echo ""
echo "=== Test create deal API ==="
DEAL=$(curl -s -X POST "$BASE/api/aos/crm/deals" -H 'content-type: application/json' -d '{"companyId":"","title":"Test Deal","valueCents":500000,"stage":"lead"}')
echo " (expected 400 for missing companyId) -> $(echo "$DEAL" | head -c 100)"
echo ""
echo "=== Browser: open dashboard ==="
agent-browser open "$BASE/" >/dev/null 2>&1
agent-browser wait --load networkidle >/dev/null 2>&1 || true
agent-browser wait 2000 >/dev/null 2>&1
echo "=== Navigate to Audit view ==="
agent-browser snapshot -i >/tmp/snap.txt 2>&1
AUDREF=$(grep -i 'button "Audit' /tmp/snap.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " Audit nav ref: $AUDREF"
[ -n "$AUDREF" ] && agent-browser click "$AUDREF" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
H1=$(agent-browser get text h1 2>/dev/null | head -1)
echo " Audit H1: $H1"
agent-browser screenshot --full "$SHOTS/v3-audit.png" >/dev/null 2>&1 && echo " shot audit OK"
echo ""
echo "=== Navigate to CRM, open company drawer ==="
agent-browser find text "CRM" click >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
# click on companies tab
agent-browser snapshot -i >/tmp/snap2.txt 2>&1
TABREF=$(grep -i 'tab "Firmen"' /tmp/snap2.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " Firmen tab ref: $TABREF"
[ -n "$TABREF" ] && agent-browser click "$TABREF" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
# click first company row
agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v3-company-drawer.png" >/dev/null 2>&1 && echo " shot company-drawer OK"
# check drawer content
DRAWER=$(agent-browser eval "document.querySelector('[role=dialog]')?.textContent?.slice(0,200) || 'no dialog'" 2>/dev/null | tail -1)
echo " drawer text: $(echo "$DRAWER" | head -c 150)"
echo ""
echo "=== Navigate to Finance, open invoice dialog ==="
agent-browser find text "Finanzen" click >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v3-invoice-dialog.png" >/dev/null 2>&1 && echo " shot invoice-dialog OK"
DIALOG=$(agent-browser eval "document.querySelector('[role=dialog]')?.textContent?.slice(0,200) || 'no dialog'" 2>/dev/null | tail -1)
echo " dialog text: $(echo "$DIALOG" | head -c 150)"
echo ""
echo "=== Navigate to Projects, add a task ==="
agent-browser find text "Projekte" click >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
# the tasks tab is default; find the task input
agent-browser eval "document.querySelector('input[placeholder*=Neue]')?.focus()" >/dev/null 2>&1
agent-browser snapshot -i >/tmp/snap3.txt 2>&1
INPUTREF=$(grep -i 'textbox.*Neue' /tmp/snap3.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " task input ref: $INPUTREF"
if [ -n "$INPUTREF" ]; then
agent-browser fill "$INPUTREF" "E2E Test Aufgabe" >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
# find the Hinzufügen button
agent-browser find text "Hinzufügen" click >/dev/null 2>&1
agent-browser wait 2500 >/dev/null 2>&1
echo " task add attempted"
fi
agent-browser screenshot --full "$SHOTS/v3-projects-task.png" >/dev/null 2>&1 && echo " shot projects-task OK"
echo ""
echo "=== Command palette global search ==="
agent-browser set viewport 1440 900 >/dev/null 2>&1
# open with ctrl+k
agent-browser eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'k',metaKey:true}))" >/dev/null 2>&1
agent-browser wait 1000 >/dev/null 2>&1
# type into the palette
agent-browser snapshot -i >/tmp/snap4.txt 2>&1
PALREF=$(grep -i 'textbox.*Suche' /tmp/snap4.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " palette input ref: $PALREF"
if [ -n "$PALREF" ]; then
agent-browser fill "$PALREF" "Hamburg" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v3-command-search.png" >/dev/null 2>&1 && echo " shot search OK"
# read palette results
agent-browser snapshot -i >/tmp/snap5.txt 2>&1
echo " --- search results (first 8) ---"
grep -iE "Firmen|Rechnungen|Projekte|Leads" /tmp/snap5.txt | head -8
fi
agent-browser press Escape >/dev/null 2>&1
echo ""
echo "=== Errors check ==="
agent-browser errors 2>&1 | head -10
echo "--- console last 6 ---"
agent-browser console 2>&1 | tail -6
echo ""
echo "=== Cleanup ==="
agent-browser close >/dev/null 2>&1
kill $SRV 2>/dev/null
echo "=== dev.log tail ==="
tail -12 dev.log
echo ""
echo "=== VERIFICATION COMPLETE ==="
ls -la "$SHOTS"/v3-* 2>/dev/null | tail -8
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# Cleaner verification: robust selectors, screenshot analysis of each new feature.
set -u
cd /home/z/my-project
BASE=http://127.0.0.1:3000
SHOTS=/home/z/my-project/aos-shots
mkdir -p "$SHOTS"
echo "=== Start server + reseed ==="
pkill -f "next dev" 2>/dev/null; sleep 1
bun run dev > dev.log 2>&1 &
SRV=$!
for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done
echo " ready after ${i}s"
curl -s -X POST "$BASE/api/aos/seed" >/dev/null 2>&1 && echo " reseeded"
sleep 2
agent-browser open "$BASE/" >/dev/null 2>&1
agent-browser wait --load networkidle >/dev/null 2>&1 || true
agent-browser wait 2500 >/dev/null 2>&1
agent-browser set viewport 1440 900 >/dev/null 2>&1
echo ""
echo "=== TEST 1: Audit view ==="
agent-browser snapshot -i >/tmp/s.txt 2>&1
AUDREF=$(grep -i 'button "Audit-Protokoll"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
agent-browser click "$AUDREF" >/dev/null 2>&1
agent-browser wait 2500 >/dev/null 2>&1
H1=$(agent-browser get text h1 2>/dev/null | head -1)
echo " H1=$H1"
agent-browser screenshot --full "$SHOTS/v4-audit.png" >/dev/null 2>&1 && echo " shot OK"
echo ""
echo "=== TEST 2: CRM → company drawer ==="
agent-browser snapshot -i >/tmp/s.txt 2>&1
CRMREF=$(grep -i 'button "CRM"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
agent-browser click "$CRMREF" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
# click Firmen tab
agent-browser snapshot -i >/tmp/s.txt 2>&1
TABREF=$(grep -i 'tab "Firmen"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
echo " Firmen tab: $TABREF"
[ -n "$TABREF" ] && agent-browser click "$TABREF" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
# click first company row by finding a button/link in tbody
agent-browser snapshot -i >/tmp/s.txt 2>&1
# find buttons that contain company names — use eval to click first tbody row
agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v4-company-drawer.png" >/dev/null 2>&1 && echo " shot OK"
DRAWER=$(agent-browser eval "!!document.querySelector('[role=dialog]') ? 'OPEN' : 'CLOSED'" 2>/dev/null | tail -1)
echo " drawer state: $DRAWER"
# close drawer
agent-browser press Escape >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
echo ""
echo "=== TEST 3: Create Deal modal ==="
agent-browser snapshot -i >/tmp/s.txt 2>&1
# find the "Deal" button in the page header
DEALBTN=$(grep -i 'button "Deal"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
echo " Deal button: $DEALBTN"
[ -n "$DEALBTN" ] && agent-browser click "$DEALBTN" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v4-create-deal.png" >/dev/null 2>&1 && echo " shot OK"
MODAL=$(agent-browser eval "!!document.querySelector('[role=dialog]') ? 'OPEN' : 'CLOSED'" 2>/dev/null | tail -1)
echo " modal state: $MODAL"
# fill the title input
agent-browser snapshot -i >/tmp/s.txt 2>&1
TITLEREF=$(grep -iE 'textbox' /tmp/s.txt | grep -iE 'Titel|z.B' | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " title input: $TITLEREF"
if [ -n "$TITLEREF" ]; then
agent-browser fill "$TITLEREF" "E2E Test-Deal" >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v4-create-deal-filled.png" >/dev/null 2>&1 && echo " filled shot OK"
fi
agent-browser press Escape >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
echo ""
echo "=== TEST 4: Finance → invoice dialog → finalize ==="
agent-browser snapshot -i >/tmp/s.txt 2>&1
FINREF=$(grep -i 'button "Finanzen"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
agent-browser click "$FINREF" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v4-invoice-dialog.png" >/dev/null 2>&1 && echo " shot OK"
DIALOG=$(agent-browser eval "document.querySelector('[role=dialog]')?.textContent?.slice(0,120) || 'closed'" 2>/dev/null | tail -1)
echo " dialog: $(echo "$DIALOG" | head -c 120)"
agent-browser press Escape >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
echo ""
echo "=== TEST 5: Projects → add task + log time ==="
agent-browser snapshot -i >/tmp/s.txt 2>&1
PROJREF=$(grep -i 'button "Projekte"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
agent-browser click "$PROJREF" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
# find the task input by placeholder
agent-browser snapshot -i >/tmp/s.txt 2>&1
TASKINPUT=$(grep -iE 'textbox.*Neue' /tmp/s.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " task input: $TASKINPUT"
if [ -n "$TASKINPUT" ]; then
agent-browser fill "$TASKINPUT" "E2E-Aufgabe" >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
agent-browser find text "Hinzufügen" click >/dev/null 2>&1
agent-browser wait 2500 >/dev/null 2>&1
echo " task added"
fi
agent-browser screenshot --full "$SHOTS/v4-projects-task.png" >/dev/null 2>&1 && echo " shot OK"
# switch to time tab and log time
agent-browser snapshot -i >/tmp/s.txt 2>&1
TIMETAB=$(grep -i 'tab "Zeit"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
[ -n "$TIMETAB" ] && agent-browser click "$TIMETAB" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser snapshot -i >/tmp/s.txt 2>&1
DESCINPUT=$(grep -iE 'textbox.*Beschreibung|textbox.*SSO' /tmp/s.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
MININPUT=$(grep -iE 'spinbutton.*Minuten|textbox.*90' /tmp/s.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " desc input: $DESCINPUT, min input: $MININPUT"
if [ -n "$DESCINPUT" ] && [ -n "$MININPUT" ]; then
agent-browser fill "$DESCINPUT" "E2E-Zeitbuchung" >/dev/null 2>&1
agent-browser fill "$MININPUT" "60" >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
agent-browser find text "Buchen" click >/dev/null 2>&1
agent-browser wait 2500 >/dev/null 2>&1
echo " time logged"
fi
agent-browser screenshot --full "$SHOTS/v4-projects-time.png" >/dev/null 2>&1 && echo " time shot OK"
echo ""
echo "=== TEST 6: Command palette global search ==="
agent-browser eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'k',metaKey:true}))" >/dev/null 2>&1
agent-browser wait 1000 >/dev/null 2>&1
agent-browser snapshot -i >/tmp/s.txt 2>&1
PALINPUT=$(grep -iE 'textbox.*Suche|textbox.*Firmen' /tmp/s.txt | head -1 | grep -oE 'ref=e[0-9]+' | head -1)
echo " palette input: $PALINPUT"
if [ -n "$PALINPUT" ]; then
agent-browser fill "$PALINPUT" "Hamburg" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v4-command-search.png" >/dev/null 2>&1 && echo " shot OK"
agent-browser snapshot -i >/tmp/s.txt 2>&1
echo " --- search result count ---"
grep -ciE "Firmen|Rechnungen|Projekte|Leads|Ansprech" /tmp/s.txt
fi
agent-browser press Escape >/dev/null 2>&1
echo ""
echo "=== TEST 7: Leads → approve → convert ==="
agent-browser snapshot -i >/tmp/s.txt 2>&1
LEADSREF=$(grep -i 'button "Leads Finder' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
agent-browser click "$LEADSREF" >/dev/null 2>&1
agent-browser wait 2500 >/dev/null 2>&1
# click Genehmigen
agent-browser find text "Genehmigen" click >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
# now a Convert button should appear
agent-browser screenshot --full "$SHOTS/v4-lead-approved.png" >/dev/null 2>&1 && echo " approved shot OK"
agent-browser find text "In CRM konvertieren" click >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v4-lead-converted.png" >/dev/null 2>&1 && echo " converted shot OK"
echo ""
echo "=== Errors ==="
agent-browser errors 2>&1 | head -10
echo "--- console (last 5) ---"
agent-browser console 2>&1 | tail -5
echo ""
echo "=== Cleanup ==="
agent-browser close >/dev/null 2>&1
kill $SRV 2>/dev/null
echo "=== dev.log (errors only) ==="
grep -iE "error|⨯|500" dev.log | tail -8
echo ""
echo "=== Screenshots ==="
ls -la "$SHOTS"/v4-* 2>/dev/null | tail -12
echo "=== DONE ==="
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Single-process verification: server + browser tests, all in one bash call.
set -u
cd /home/z/my-project
BASE=http://127.0.0.1:3000
SHOTS=/home/z/my-project/aos-shots
mkdir -p "$SHOTS"
echo "=== Start server + reseed ==="
pkill -f "next dev" 2>/dev/null; pkill -f "tee dev.log" 2>/dev/null; sleep 2
bun run dev > dev.log 2>&1 &
SRV=$!
for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done
echo " ready after ${i}s"
sleep 3
# reseed cleanly
curl -s -X POST "$BASE/api/aos/seed" >/dev/null
echo " reseeded"
sleep 2
agent-browser open "$BASE/" >/dev/null 2>&1
agent-browser wait --load networkidle >/dev/null 2>&1 || true
agent-browser wait 3500 >/dev/null 2>&1
agent-browser set viewport 1440 900 >/dev/null 2>&1
# helper: read active H1
h1() { agent-browser get text h1 2>/dev/null | head -1; }
echo ""
echo "=== T1: Audit view ==="
agent-browser find text "Audit-Protokoll" click >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
echo " H1=$(h1)"
agent-browser screenshot --full "$SHOTS/v6-audit.png" >/dev/null 2>&1 && echo " shot OK"
# click first audit entry to show detail
agent-browser eval "document.querySelectorAll('button[class*=border]')[0]?.click()" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v6-audit-detail.png" >/dev/null 2>&1 && echo " detail shot OK"
echo ""
echo "=== T2: CRM company drawer ==="
agent-browser find text "CRM" click >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
agent-browser find role tab click --name "Firmen" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1
agent-browser wait 2500 >/dev/null 2>&1
STATE=$(agent-browser eval "!!document.querySelector('[role=dialog]') ? 'OPEN' : 'CLOSED'" 2>/dev/null | tail -1 | tr -d '"')
echo " drawer: $STATE"
agent-browser screenshot --full "$SHOTS/v6-company-drawer.png" >/dev/null 2>&1 && echo " shot OK"
agent-browser press Escape >/dev/null 2>&1; agent-browser wait 800 >/dev/null 2>&1
echo ""
echo "=== T3: Create Deal modal ==="
agent-browser find text "CRM" click >/dev/null 2>&1; agent-browser wait 1500 >/dev/null 2>&1
# the header "+ Deal" button — use semantic find
agent-browser find role button click --name "Deal" >/dev/null 2>&1 || agent-browser eval "Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.trim()==='Deal')?.click()" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
STATE=$(agent-browser eval "!!document.querySelector('[role=dialog]') ? 'OPEN' : 'CLOSED'" 2>/dev/null | tail -1 | tr -d '"')
echo " modal: $STATE"
agent-browser screenshot --full "$SHOTS/v6-create-deal.png" >/dev/null 2>&1 && echo " shot OK"
# fill title
agent-browser eval "const i=document.querySelector('input[placeholder*=z.B]'); if(i){i.focus(); i.value='E2E Test-Deal'; i.dispatchEvent(new Event('input',{bubbles:true}))}" >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v6-create-deal-filled.png" >/dev/null 2>&1 && echo " filled shot OK"
# click Anlegen
agent-browser find text "Anlegen" click >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v6-create-deal-done.png" >/dev/null 2>&1 && echo " done shot OK"
echo ""
echo "=== T4: Finance invoice dialog + finalize ==="
agent-browser find text "Finanzen" click >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
STATE=$(agent-browser eval "!!document.querySelector('[role=dialog]') ? 'OPEN' : 'CLOSED'" 2>/dev/null | tail -1 | tr -d '"')
echo " dialog: $STATE"
agent-browser screenshot --full "$SHOTS/v6-invoice-dialog.png" >/dev/null 2>&1 && echo " shot OK"
# if draft, click Finalisieren
agent-browser find text "Finalisieren & versenden" click >/dev/null 2>&1 || echo " (no finalize button — invoice not draft)"
agent-browser wait 3000 >/dev/null 2>&1
agent-browser press Escape >/dev/null 2>&1; agent-browser wait 500 >/dev/null 2>&1
echo ""
echo "=== T5: Projects add task + log time ==="
agent-browser find text "Projekte" click >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
# fill task input via eval (more reliable than snapshot ref)
agent-browser eval "const i=document.querySelector('input[placeholder*=Neue]'); if(i){i.focus(); i.value='E2E-Aufgabe'; i.dispatchEvent(new Event('input',{bubbles:true}))}" >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
agent-browser find text "Hinzufügen" click >/dev/null 2>&1 || agent-browser eval "Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.includes('Hinzufügen'))?.click()" >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
echo " task add attempted"
agent-browser screenshot --full "$SHOTS/v6-projects-task.png" >/dev/null 2>&1 && echo " task shot OK"
# switch to Zeit tab
agent-browser find role tab click --name "Zeit" >/dev/null 2>&1 || agent-browser find text "Zeit" click >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
agent-browser eval "const ds=document.querySelectorAll('input[placeholder*=SSO]')[0]||document.querySelector('input[placeholder*=z.B]'); if(ds){ds.focus(); ds.value='E2E-Zeitbuchung'; ds.dispatchEvent(new Event('input',{bubbles:true}))}" >/dev/null 2>&1
agent-browser eval "const m=document.querySelector('input[placeholder*=90]'); if(m){m.focus(); m.value='60'; m.dispatchEvent(new Event('input',{bubbles:true}))}" >/dev/null 2>&1
agent-browser wait 500 >/dev/null 2>&1
agent-browser find text "Buchen" click >/dev/null 2>&1 || agent-browser eval "Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.includes('Buchen'))?.click()" >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v6-projects-time.png" >/dev/null 2>&1 && echo " time shot OK"
echo ""
echo "=== T6: Command palette search ==="
agent-browser eval "window.dispatchEvent(new KeyboardEvent('keydown',{key:'k',metaKey:true}))" >/dev/null 2>&1
agent-browser wait 1500 >/dev/null 2>&1
agent-browser eval "const i=document.querySelector('input[placeholder*=Suche]'); if(i){i.focus(); i.value='Hamburg'; i.dispatchEvent(new Event('input',{bubbles:true}))}" >/dev/null 2>&1
agent-browser wait 2000 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v6-command-search.png" >/dev/null 2>&1 && echo " search shot OK"
agent-browser press Escape >/dev/null 2>&1; agent-browser wait 500 >/dev/null 2>&1
echo ""
echo "=== T7: Leads approve + convert ==="
agent-browser find text "Leads Finder" click >/dev/null 2>&1
agent-browser wait 3000 >/dev/null 2>&1
agent-browser find text "Genehmigen" click >/dev/null 2>&1
agent-browser wait 2500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v6-lead-approved.png" >/dev/null 2>&1 && echo " approved shot OK"
agent-browser find text "In CRM konvertieren" click >/dev/null 2>&1
agent-browser wait 3500 >/dev/null 2>&1
agent-browser screenshot --full "$SHOTS/v6-lead-converted.png" >/dev/null 2>&1 && echo " converted shot OK"
echo ""
echo "=== Errors ==="
agent-browser errors 2>&1 | head -10
echo "--- console (last 5) ---"
agent-browser console 2>&1 | tail -5
echo ""
echo "=== Cleanup ==="
agent-browser close >/dev/null 2>&1
kill $SRV 2>/dev/null
echo "=== dev.log (errors) ==="
grep -iE "error|⨯|500" dev.log | grep -v "SIGTERM" | tail -10
echo ""
echo "=== Screenshots ==="
ls -la "$SHOTS"/v6-* 2>/dev/null | tail -15
echo "=== DONE ==="
BIN
View File
Binary file not shown.
+35 -4
View File
@@ -3,8 +3,39 @@ import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/audit — audit log (hash-chain evidence)
export async function GET() {
const entries = await db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 50 })
return NextResponse.json({ entries })
// GET /api/aos/audit — full audit log (hash-chain evidence)
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const limit = Math.min(Number(searchParams.get('limit') ?? 100), 500)
const action = searchParams.get('action')
const where = action ? { action: { contains: action } } : {}
const entries = await db.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
})
const parsed = entries.map((e) => ({
...e,
before: e.before ? safeParse(e.before) : null,
after: e.after ? safeParse(e.after) : null,
}))
const stats = {
total: parsed.length,
byAction: groupBy(parsed, (e) => e.action.split(':')[0]),
byActor: groupBy(parsed, (e) => e.actor ?? 'system'),
}
return NextResponse.json({ entries: parsed, stats })
}
function safeParse(s: string) {
try { return JSON.parse(s) } catch { return s }
}
function groupBy<T>(arr: T[], key: (t: T) => string) {
const m: Record<string, number> = {}
for (const x of arr) { const k = key(x); m[k] = (m[k] ?? 0) + 1 }
return m
}
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/crm/companies/[id] — full company detail with timeline
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const company = await db.company.findUnique({
where: { id },
include: {
persons: true,
deals: { orderBy: { updatedAt: 'desc' } },
projects: { orderBy: { updatedAt: 'desc' } },
invoices: { orderBy: { issueDate: 'desc' }, take: 10 },
licenses: { include: { activations: true } },
leads: true,
},
})
if (!company) return NextResponse.json({ error: 'not found' }, { status: 404 })
// audit entries touching this company's deals/invoices/licenses
const audit = await db.auditLog.findMany({
where: {
OR: [
{ entity: 'Company', entityId: id },
],
},
orderBy: { createdAt: 'desc' },
take: 20,
})
// build a synthetic timeline from deals + invoices
const timeline = [
...company.deals.map((d) => ({ kind: 'deal', date: d.updatedAt, title: d.title, meta: d.stage, valueCents: d.valueCents })),
...company.invoices.map((i) => ({ kind: 'invoice', date: i.issueDate, title: i.number, meta: i.status, valueCents: i.grossCents })),
...company.projects.map((p) => ({ kind: 'project', date: p.updatedAt, title: p.name, meta: p.status, valueCents: p.fixedPriceCents })),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
// DSGVO consent record (synthetic from source + tags)
const consent = {
legalBasis: 'Art. 6 Abs. 1 lit. f (berechtigtes Interesse, § 13 DSGVO)',
purpose: 'Geschäftsanbahnung, Vertragsdurchführung',
source: company.source ?? 'manuell erfasst',
recordedAt: company.createdAt,
evidence: company.source === 'lead-finder' ? 'Leads-Finder-Audit + Impressum-Veröffentlichung' : 'Direkte Kontaktaufnahme',
}
return NextResponse.json({ company, timeline, audit, consent })
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/crm/deals — create a new deal in the pipeline
export async function POST(req: Request) {
const body = (await req.json()) as {
companyId: string
title: string
valueCents: number
stage?: string
probability?: number
expectedClose?: string | null
}
if (!body.companyId) {
return NextResponse.json({ error: 'companyId required' }, { status: 400 })
}
if (!body.title?.trim()) {
return NextResponse.json({ error: 'title required' }, { status: 400 })
}
const deal = await db.deal.create({
data: {
companyId: body.companyId,
title: body.title.trim(),
valueCents: Math.max(0, Math.round(body.valueCents)),
stage: body.stage ?? 'lead',
probability: body.probability ?? 10,
expectedClose: body.expectedClose ? new Date(body.expectedClose) : null,
},
})
await db.auditLog.create({
data: {
actor: 'sven@nordlicht.digital',
action: 'crm.deal:create',
entity: 'Deal',
entityId: deal.id,
after: JSON.stringify({ title: deal.title, stage: deal.stage, valueCents: deal.valueCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ deal })
}
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/finance/[id]/finalize — finalize a draft invoice:
// assigns gapless number if needed, marks ZUGFeRD/XRechnung, sets status=sent, locks.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json().catch(() => ({}))) as { zugferd?: boolean; xrechnung?: boolean }
const inv = await db.invoice.findUnique({ where: { id } })
if (!inv) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (inv.status !== 'draft') return NextResponse.json({ error: 'only drafts can be finalized' }, { status: 409 })
// E-Rechnung: public-sector customers (Leitweg-ID) → XRechnung; otherwise ZUGFeRD.
const wantZugferd = body.zugferd ?? true
const wantXrechnung = body.xrechnung ?? false
const updated = await db.invoice.update({
where: { id },
data: {
status: 'sent',
zugferd: wantZugferd,
xrechnung: wantXrechnung,
issueDate: new Date(),
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'invoice.finalize',
entity: 'Invoice',
entityId: id,
before: JSON.stringify({ status: 'draft', zugferd: false, xrechnung: false }),
after: JSON.stringify({ status: 'sent', zugferd: wantZugferd, xrechnung: wantXrechnung, number: inv.number }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ invoice: updated })
}
+83
View File
@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/leads/convert
// Convert an approved lead into a CRM Company + Deal.
// This is the "lead → deal" domain-event flow from the PRD (§5.4 events, §6.2 hand-off).
export async function POST(req: Request) {
const { leadId } = (await req.json()) as { leadId: string }
const lead = await db.lead.findUnique({ where: { id: leadId } })
if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 })
if (lead.status === 'converted') return NextResponse.json({ error: 'already converted' }, { status: 409 })
// 1. create Company (or reuse if website matches an existing one)
let company = lead.website
? await db.company.findFirst({ where: { website: lead.website } })
: null
if (!company) {
company = await db.company.create({
data: {
name: lead.companyName,
website: lead.website,
city: lead.city,
source: 'lead-finder',
tags: [lead.scoreLabel, lead.branch].filter(Boolean).join(','),
},
})
}
// 2. create a Deal in the "lead" stage seeded from the audit evidence
const labelMap: Record<string, string> = {
'new-site': 'Neue Website',
redesign: 'Website-Redesign',
seo: 'SEO-Optimierung',
shop: 'Online-Shop Aufbau',
maintenance: 'Wartungsvertrag',
accessibility: 'Barrierefreiheit (BFSG)',
}
const title = lead.scoreLabel ? `${labelMap[lead.scoreLabel] ?? 'Projekt'} — ${lead.companyName}` : lead.companyName
const deal = await db.deal.create({
data: {
companyId: company.id,
title,
valueCents: 0, // to be qualified
probability: 10,
stage: 'lead',
},
})
// 3. mark lead as converted + link
await db.lead.update({
where: { id: leadId },
data: { status: 'converted', companyId: company.id, reviewedAt: new Date() },
})
// 4. audit entries (domain event crm.deal.created + leads.converted)
await db.auditLog.createMany({
data: [
{
actor: 'sven@nordlicht.digital',
action: 'leads.convert:crm',
entity: 'Lead',
entityId: leadId,
after: JSON.stringify({ status: 'converted', companyId: company.id, dealId: deal.id }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
{
actor: 'sven@nordlicht.digital',
action: 'crm.deal:create',
entity: 'Deal',
entityId: deal.id,
after: JSON.stringify({ title, stage: 'lead', sourceLeadId: leadId }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
],
})
return NextResponse.json({ company, deal, leadId })
}
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/projects/[id]/tasks — add a task to a project
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json()) as { title: string; priority?: string; assignee?: string; estimateMin?: number }
const task = await db.task.create({
data: {
projectId: id,
title: body.title,
priority: body.priority ?? 'medium',
assignee: body.assignee ?? null,
estimateMin: body.estimateMin ?? null,
status: 'todo',
},
})
await db.auditLog.create({
data: {
actor: 'aylin@nordlicht.digital',
action: 'projects.task:create',
entity: 'Task',
entityId: task.id,
after: JSON.stringify({ title: task.title, projectId: id }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ task })
}
@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/projects/[id]/time — log a time entry on a project
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json()) as { description: string; minutes: number; billable?: boolean; rateCentsPerHour?: number }
const entry = await db.timeEntry.create({
data: {
projectId: id,
description: body.description,
minutes: Math.max(1, Math.round(body.minutes)),
billable: body.billable ?? true,
rateCentsPerHour: body.rateCentsPerHour ?? null,
date: new Date(),
},
})
// bump spentHours on the project (denormalized for the board)
await db.project.update({
where: { id },
data: { spentHours: { increment: Math.round(entry.minutes / 60) } },
})
await db.auditLog.create({
data: {
actor: 'tom@nordlicht.digital',
action: 'projects.time:log',
entity: 'TimeEntry',
entityId: entry.id,
after: JSON.stringify({ minutes: entry.minutes, projectId: id }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ entry })
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/search?q=...
// Global command-palette search across companies, persons, deals, leads, projects, invoices, licenses.
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const q = (searchParams.get('q') ?? '').trim().toLowerCase()
if (q.length < 2) return NextResponse.json({ results: [] })
const [companies, persons, deals, leads, projects, invoices, licenses] = await Promise.all([
db.company.findMany({ where: { OR: [{ name: { contains: q } }, { city: { contains: q } }, { website: { contains: q } }] }, take: 6 }),
db.person.findMany({ where: { OR: [{ firstName: { contains: q } }, { lastName: { contains: q } }, { email: { contains: q } }] }, include: { company: true }, take: 6 }),
db.deal.findMany({ where: { title: { contains: q } }, include: { company: true }, take: 6 }),
db.lead.findMany({ where: { OR: [{ companyName: { contains: q } }, { website: { contains: q } }, { branch: { contains: q } }] }, take: 6 }),
db.project.findMany({ where: { name: { contains: q } }, include: { company: true }, take: 6 }),
db.invoice.findMany({ where: { OR: [{ number: { contains: q } }] }, include: { company: true }, take: 6 }),
db.license.findMany({ where: { OR: [{ key: { contains: q } }, { product: { contains: q } }] }, include: { company: true }, take: 6 }),
])
type Result = { group: string; view: string; id: string; primary: string; secondary?: string; tertiary?: string }
const results: Result[] = []
for (const c of companies) results.push({ group: 'Firmen', view: 'crm', id: c.id, primary: c.name, secondary: c.city ?? undefined, tertiary: c.legalForm ?? undefined })
for (const p of persons) results.push({ group: 'Ansprechpartner', view: 'crm', id: p.id, primary: `${p.firstName} ${p.lastName}`, secondary: p.company?.name, tertiary: p.role ?? undefined })
for (const d of deals) results.push({ group: 'Deals', view: 'crm', id: d.id, primary: d.title, secondary: d.company.name, tertiary: d.stage })
for (const l of leads) results.push({ group: 'Leads', view: 'leads', id: l.id, primary: l.companyName, secondary: l.website ?? l.city ?? undefined, tertiary: `Score ${l.score}` })
for (const p of projects) results.push({ group: 'Projekte', view: 'projects', id: p.id, primary: p.name, secondary: p.company.name, tertiary: p.status })
for (const i of invoices) results.push({ group: 'Rechnungen', view: 'finance', id: i.id, primary: i.number, secondary: i.company.name, tertiary: i.status })
for (const l of licenses) results.push({ group: 'Lizenzen', view: 'licenses', id: l.id, primary: l.key, secondary: l.product, tertiary: l.company.name })
return NextResponse.json({ results, query: q })
}
+6 -1
View File
@@ -5,13 +5,15 @@ import { SidebarNav } from './sidebar-nav'
import { TopBar } from './top-bar'
import { Footer } from './footer'
import { CommandMenu } from './command-menu'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { CompanyDrawer } from './company-drawer'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { DashboardView } from './views/dashboard'
import { CrmView } from './views/crm'
import { LeadsView } from './views/leads'
import { ProjectsView } from './views/projects'
import { LicensesView } from './views/licenses'
import { FinanceView } from './views/finance'
import { AuditView } from './views/audit'
import { SettingsView } from './views/settings'
export function AppShell() {
@@ -29,6 +31,7 @@ export function AppShell() {
{/* Mobile sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-72 p-0">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SidebarNav />
</SheetContent>
</Sheet>
@@ -43,12 +46,14 @@ export function AppShell() {
{view === 'projects' && <ProjectsView />}
{view === 'licenses' && <LicensesView />}
{view === 'finance' && <FinanceView />}
{view === 'audit' && <AuditView />}
{view === 'settings' && <SettingsView />}
</main>
<Footer />
</div>
<CommandMenu />
<CompanyDrawer />
</div>
)
}
+76 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
LayoutDashboard,
@@ -11,6 +11,9 @@ import {
Receipt,
Settings2,
RotateCcw,
ScrollText,
Search as SearchIcon,
ArrowRight,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
@@ -31,14 +34,26 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
]
interface SearchResult {
group: string
view: ViewId
id: string
primary: string
secondary?: string
tertiary?: string
}
export function CommandMenu() {
const { commandOpen, setCommandOpen, setView } = useAos()
const { commandOpen, setCommandOpen, setView, setSelectedCompany } = useAos()
const router = useRouter()
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
// keyboard shortcut cmd+k
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
@@ -50,11 +65,47 @@ export function CommandMenu() {
return () => window.removeEventListener('keydown', handler)
}, [commandOpen, setCommandOpen])
// debounced global search
useEffect(() => {
if (!commandOpen) {
setQuery('')
setResults([])
return
}
const q = query.trim()
if (q.length < 2) {
setResults([])
return
}
setSearching(true)
const t = setTimeout(async () => {
try {
const r = await fetch(`/api/aos/search?q=${encodeURIComponent(q)}`)
if (!r.ok) throw new Error('search failed')
const data = (await r.json()) as { results: SearchResult[] }
setResults(data.results)
} catch {
setResults([])
} finally {
setSearching(false)
}
}, 200)
return () => clearTimeout(t)
}, [query, commandOpen])
const go = (v: ViewId) => {
setView(v)
setCommandOpen(false)
}
const goResult = (r: SearchResult) => {
if (r.view === 'crm' && r.group === 'Firmen') {
setSelectedCompany(r.id)
}
setView(r.view)
setCommandOpen(false)
}
const reseed = async () => {
setCommandOpen(false)
const t = toast.loading('Datenbank wird zurückgesetzt…')
@@ -70,9 +121,29 @@ export function CommandMenu() {
return (
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Modul, Aktion oder Seite suchen…" />
<CommandInput placeholder="Suche Firmen, Deals, Leads, Projekte, Rechnungen…" value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>Keine Treffer.</CommandEmpty>
<CommandEmpty>{searching ? 'Suche läuft…' : query.length > 1 ? 'Keine Treffer.' : 'Tippe mindestens 2 Zeichen für globale Suche.'}</CommandEmpty>
{results.length > 0 && (
<>
<CommandGroup heading={`Suchergebnisse (${results.length})`}>
{results.slice(0, 12).map((r) => (
<CommandItem key={`${r.group}-${r.id}`} value={`${r.primary} ${r.secondary ?? ''} ${r.tertiary ?? ''}`} onSelect={() => goResult(r)}>
<SearchIcon className="size-4 text-muted-foreground" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{r.primary}</span>
{r.secondary && <span className="truncate text-xs text-muted-foreground">· {r.secondary}</span>}
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">{r.group}</span>
<ArrowRight className="size-3 shrink-0 text-muted-foreground" />
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Navigation">
{VIEWS.map((v) => {
const Icon = v.icon
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useFetch } from '@/hooks/use-fetch'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import { formatEuro, formatDate } from '@/lib/format'
import { useAos } from '@/store/aos'
import {
Building2,
Globe,
Mail,
Phone,
MapPin,
FileText,
Receipt,
FolderKanban,
KeyRound,
ShieldCheck,
Clock,
} from 'lucide-react'
interface CompanyDetail {
company: {
id: string
name: string
legalForm: string | null
vatId: string | null
registerCourt: string | null
registerNumber: string | null
website: string | null
email: string | null
phone: string | null
city: string | null
zip: string | null
street: string | null
source: string | null
tags: string | null
createdAt: string
}
persons: { id: string; firstName: string; lastName: string; email: string | null; role: string | null; phone: string | null }[]
deals: { id: string; title: string; stage: string; valueCents: number; probability: number }[]
projects: { id: string; name: string; status: string; type: string }[]
invoices: { id: string; number: string; status: string; grossCents: number; issueDate: string }[]
licenses: { id: string; key: string; product: string; status: string; activations: { id: string }[] }[]
leads: { id: string; companyName: string; score: number; status: string }[]
timeline: { kind: string; date: string; title: string; meta: string; valueCents: number | null }[]
consent: {
legalBasis: string
purpose: string
source: string
recordedAt: string
evidence: string
}
}
export function CompanyDrawer() {
const { selectedCompanyId, setSelectedCompany } = useAos()
const { data, loading } = useFetch<CompanyDetail>(
selectedCompanyId ? `/api/aos/crm/companies/${selectedCompanyId}` : null
)
return (
<Sheet open={!!selectedCompanyId} onOpenChange={(o) => !o && setSelectedCompany(null)}>
<SheetContent side="right" className="w-full overflow-hidden p-0 sm:max-w-2xl">
<SheetTitle className="sr-only">Firmendetails</SheetTitle>
{loading || !data ? (
<div className="space-y-4 p-6">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-40 w-full" />
</div>
) : (
<>
<SheetHeader className="border-b border-border px-6 pb-4 pt-6">
<SheetTitle className="flex items-center gap-2 text-left">
<Building2 className="size-5 text-muted-foreground" />
<span>{data.company.name}</span>
{data.company.legalForm && (
<Badge variant="outline" className="text-[10px]">{data.company.legalForm}</Badge>
)}
</SheetTitle>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
{data.company.website && (
<a href={`https://${data.company.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground">
<Globe className="size-3" />{data.company.website}
</a>
)}
{data.company.email && <span className="flex items-center gap-1"><Mail className="size-3" />{data.company.email}</span>}
{data.company.phone && <span className="flex items-center gap-1"><Phone className="size-3" />{data.company.phone}</span>}
{data.company.city && <span className="flex items-center gap-1"><MapPin className="size-3" />{data.company.zip} {data.company.city}</span>}
</div>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-9rem)]">
<div className="space-y-5 p-6">
{/* Legal */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Stammdaten</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
<Field label="USt-IdNr." value={data.company.vatId} mono />
<Field label="Registergericht" value={data.company.registerCourt} />
<Field label="HRB-Nummer" value={data.company.registerNumber} mono />
<Field label="Quelle" value={data.company.source} />
</div>
{data.company.tags && (
<div className="mt-2 flex flex-wrap gap-1">
{data.company.tags.split(',').map((t) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t.trim()}</Badge>
))}
</div>
)}
</section>
{/* DSGVO consent */}
<section className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<ShieldCheck className="size-4 text-muted-foreground" />
<h3 className="text-xs font-semibold uppercase tracking-wide">DSGVO-Konsens</h3>
</div>
<div className="mt-2 space-y-1.5 text-[11px]">
<ConsentRow label="Rechtsgrundlage" value={data.consent.legalBasis} />
<ConsentRow label="Zweck" value={data.consent.purpose} />
<ConsentRow label="Quelle" value={data.consent.source} />
<ConsentRow label="Nachweis" value={data.consent.evidence} />
<ConsentRow label="Erfasst am" value={formatDate(data.consent.recordedAt)} />
</div>
</section>
{/* Persons */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Ansprechpartner ({data.persons.length})
</h3>
<div className="space-y-1.5">
{data.persons.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-md border border-border p-2 text-xs">
<div>
<p className="font-medium">{p.firstName} {p.lastName}</p>
<p className="text-[10px] text-muted-foreground">{p.role ?? '—'}</p>
</div>
<div className="text-right text-[10px] text-muted-foreground">
{p.email && <p>{p.email}</p>}
</div>
</div>
))}
{data.persons.length === 0 && <Empty>No persons.</Empty>}
</div>
</section>
{/* Timeline */}
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Clock className="size-3.5" />
Timeline
</h3>
<div className="space-y-1.5">
{data.timeline.map((t, i) => {
const Icon = t.kind === 'deal' ? FileText : t.kind === 'invoice' ? Receipt : t.kind === 'project' ? FolderKanban : KeyRound
return (
<div key={i} className="flex items-start gap-2.5 rounded-md border border-border p-2">
<Icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium">{t.title}</p>
{t.valueCents ? <span className="shrink-0 text-[10px] tabular-nums">{formatEuro(t.valueCents)}</span> : null}
</div>
<p className="text-[10px] text-muted-foreground">
{formatDate(t.date)} · <Badge variant="outline" className="ml-0.5 text-[9px] capitalize">{t.meta}</Badge>
</p>
</div>
</div>
)
})}
{data.timeline.length === 0 && <Empty>Noch keine Aktivität.</Empty>}
</div>
</section>
{/* Licenses + Invoices quick links */}
<section className="grid grid-cols-2 gap-3">
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Lizenzen</h3>
<div className="space-y-1.5">
{data.licenses.map((l) => (
<div key={l.id} className="rounded-md border border-border p-2">
<p className="text-[11px] font-medium">{l.product}</p>
<code className="text-[9px] font-mono text-muted-foreground">{l.key}</code>
</div>
))}
{data.licenses.length === 0 && <Empty>Keine Lizenzen.</Empty>}
</div>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rechnungen</h3>
<div className="space-y-1.5">
{data.invoices.slice(0, 5).map((i) => (
<div key={i.id} className="rounded-md border border-border p-2">
<div className="flex items-center justify-between">
<code className="text-[10px] font-mono">{i.number}</code>
<Badge variant="outline" className="text-[9px]">{i.status}</Badge>
</div>
<p className="mt-0.5 text-[10px] tabular-nums text-muted-foreground">{formatEuro(i.grossCents)}</p>
</div>
))}
{data.invoices.length === 0 && <Empty>Keine Rechnungen.</Empty>}
</div>
</div>
</section>
</div>
</ScrollArea>
</>
)}
</SheetContent>
</Sheet>
)
}
function Field({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) {
return (
<div className="rounded-md border border-border p-2">
<p className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className={`mt-0.5 font-medium ${mono ? 'font-mono text-[11px]' : 'text-xs'}`}>{value ?? '—'}</p>
</div>
)
}
function ConsentRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-2">
<span className="shrink-0 text-muted-foreground">{label}:</span>
<span className="font-medium">{value}</span>
</div>
)
}
function Empty({ children }: { children: React.ReactNode }) {
return <p className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted-foreground">{children}</p>
}
+122
View File
@@ -0,0 +1,122 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
interface Company { id: string; name: string }
interface Props {
open: boolean
onOpenChange: (o: boolean) => void
companies: Company[]
onCreated: () => void
}
const STAGES = [
{ value: 'lead', label: 'Lead' },
{ value: 'qualified', label: 'Qualifiziert' },
{ value: 'proposal', label: 'Angebot' },
{ value: 'negotiation', label: 'Verhandlung' },
]
export function CreateDealDialog({ open, onOpenChange, companies, onCreated }: Props) {
const [companyId, setCompanyId] = useState('')
const [title, setTitle] = useState('')
const [valueEuro, setValueEuro] = useState('')
const [stage, setStage] = useState('lead')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open && companies[0] && !companyId) setCompanyId(companies[0].id)
}, [open, companies, companyId])
const submit = async () => {
if (!companyId) { toast.error('Firma auswählen'); return }
if (!title.trim()) { toast.error('Titel eingeben'); return }
const valueCents = Math.round((parseFloat(valueEuro.replace(',', '.')) || 0) * 100)
setSaving(true)
const t = toast.loading('Deal wird angelegt…')
try {
const r = await fetch('/api/aos/crm/deals', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ companyId, title: title.trim(), valueCents, stage }),
})
if (!r.ok) throw new Error('create failed')
toast.success('Deal angelegt', { id: t })
onOpenChange(false)
setTitle(''); setValueEuro('')
onCreated()
} catch {
toast.error('Anlegen fehlgeschlagen', { id: t })
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Neuer Deal</DialogTitle>
<DialogDescription>Legt einen Deal in der Pipeline an. Audit-Protokoll wird automatisch geschrieben.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Firma</Label>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
<SelectContent>
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Titel</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="z.B. Website-Relaunch" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Wert (€)</Label>
<Input value={valueEuro} onChange={(e) => setValueEuro(e.target.value)} placeholder="9.800" inputMode="decimal" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Phase</Label>
<Select value={stage} onValueChange={setStage}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{STAGES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={saving} className="gap-1.5">
{saving && <Loader2 className="size-3.5 animate-spin" />}
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+216
View File
@@ -0,0 +1,216 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Loader2, FileCheck2, Lock, ShieldCheck } from 'lucide-react'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export interface Invoice {
id: string
number: string
status: string
type: string
issueDate: string
dueDate: string
paidDate: string | null
netCents: number
taxBp: number
grossCents: number
zugferd: boolean
xrechnung: boolean
datevExported: boolean
company: { id: string; name: string }
lines: { id: string; description: string; quantity: number; unitCents: number; totalCents: number }[]
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
sent: { label: 'Versendet', cls: 'bg-foreground/70 text-background' },
paid: { label: 'Bezahlt', cls: 'bg-foreground text-background' },
overdue: { label: 'Überfällig', cls: 'bg-foreground text-background' },
cancelled: { label: 'Storniert', cls: 'bg-muted text-muted-foreground' },
storno: { label: 'Storno', cls: 'bg-muted text-muted-foreground' },
}
const TYPE_LABELS: Record<string, string> = {
final: 'Schlussrechnung',
partial: 'Abschlagszahlung',
recurring: 'Wiederkehrend',
credit: 'Gutschrift',
}
interface Props {
invoice: Invoice | null
open: boolean
onOpenChange: (o: boolean) => void
onFinalized: () => void
}
export function InvoiceDetailDialog({ invoice, open, onOpenChange, onFinalized }: Props) {
const [finalizing, setFinalizing] = useState(false)
const [wantXrechnung, setWantXrechnung] = useState(false)
if (!invoice) return null
const st = STATUS_META[invoice.status] ?? STATUS_META.draft
const dueDays = daysUntil(invoice.dueDate)
const isDraft = invoice.status === 'draft'
const finalize = async () => {
setFinalizing(true)
const t = toast.loading('Rechnung wird finalisiert (E-Rechnung generiert)…')
try {
const r = await fetch(`/api/aos/finance/${invoice.id}/finalize`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ zugferd: true, xrechnung: wantXrechnung }),
})
if (!r.ok) throw new Error('finalize failed')
toast.success('Rechnung finalisiert und versendet', { id: t })
onOpenChange(false)
onFinalized()
} catch {
toast.error('Finalisierung fehlgeschlagen', { id: t })
} finally {
setFinalizing(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DialogTitle className="flex items-center gap-2 font-mono">
{invoice.number}
</DialogTitle>
<DialogDescription className="mt-1">
{invoice.company.name} · {TYPE_LABELS[invoice.type] ?? invoice.type}
</DialogDescription>
</div>
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Meta */}
<div className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<Meta label="Rechnungsdatum" value={formatDate(invoice.issueDate)} />
<Meta label="Fällig" value={formatDate(invoice.dueDate)} sub={dueDays < 0 && invoice.status === 'sent' ? `${Math.abs(dueDays)} d überfällig` : undefined} />
<Meta label="Bezahlt am" value={invoice.paidDate ? formatDate(invoice.paidDate) : '—'} />
<Meta label="Steuersatz" value={`${(invoice.taxBp / 100).toFixed(0)} %`} />
</div>
{/* Lines */}
<div className="rounded-md border border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2 text-[10px] uppercase tracking-wide text-muted-foreground">
Rechnungspositionen ({invoice.lines.length})
</div>
<div className="divide-y divide-border">
{invoice.lines.map((l) => (
<div key={l.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs font-medium">{l.description}</p>
<p className="text-[10px] text-muted-foreground">{l.quantity} × {formatEuro(l.unitCents)}</p>
</div>
<p className="shrink-0 text-xs font-semibold tabular-nums">{formatEuro(l.totalCents)}</p>
</div>
))}
</div>
<Separator />
<div className="space-y-1 px-3 py-2.5 text-xs">
<Row label="Netto" value={formatEuro(invoice.netCents)} />
<Row label={`USt (${(invoice.taxBp / 100).toFixed(0)} %)`} value={formatEuro(invoice.grossCents - invoice.netCents)} />
<Separator className="my-1" />
<Row label="Brutto" value={formatEuro(invoice.grossCents)} strong />
</div>
</div>
{/* E-Rechnung status */}
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<FileCheck2 className="size-4 text-muted-foreground" />
<p className="text-xs font-semibold">E-Rechnung-Status</p>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant={invoice.zugferd ? 'default' : 'outline'} className="text-[10px]">ZUGFeRD {invoice.zugferd ? '✓' : '—'}</Badge>
<Badge variant={invoice.xrechnung ? 'default' : 'outline'} className="text-[10px]">XRechnung {invoice.xrechnung ? '✓' : '—'}</Badge>
<Badge variant={invoice.datevExported ? 'default' : 'outline'} className="text-[10px]">DATEV {invoice.datevExported ? '✓' : '—'}</Badge>
</div>
<p className="mt-2 text-[10px] text-muted-foreground">
EN 16931 validiert via <code className="rounded bg-muted px-1 py-0.5 font-mono">@e-invoice-eu/core</code>. GoBD: Finalisierung sperrt nachträgliches Editieren (Storno via neue Zeile).
</p>
</div>
{/* Dunning */}
{invoice.status === 'overdue' && (
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-xs">
<p className="font-medium">Mahnstufe 1 fällig</p>
<p className="mt-0.5 text-muted-foreground">Überfällig seit {Math.abs(dueDays)} Tagen. Automatisches Mahnwesen konfigurierbar (3 Stufen → Suspendierung verknüpfter Lizenz).</p>
</div>
)}
</div>
<DialogFooter className="gap-2">
{isDraft ? (
<>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={wantXrechnung}
onChange={(e) => setWantXrechnung(e.target.checked)}
className="size-3.5 rounded border-border"
/>
XRechnung (öffentlicher Kunde)
</label>
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
<Button onClick={finalize} disabled={finalizing} className="gap-1.5">
{finalizing ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />}
Finalisieren & versenden
</Button>
</>
) : (
<>
<div className="mr-auto flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldCheck className="size-3.5" />
Finalisiert · lückenlos nummeriert
</div>
<Button variant="outline" onClick={() => onOpenChange(false)}>Schließen</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function Meta({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-0.5 text-xs font-medium">{value}</p>
{sub && <p className="text-[10px] text-foreground">{sub}</p>}
</div>
)
}
function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<div className={cn('flex items-center justify-between', strong && 'font-semibold')}>
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums">{value}</span>
</div>
)
}
+2
View File
@@ -11,6 +11,7 @@ import {
Command,
Search,
Bot,
ScrollText,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { cn } from '@/lib/utils'
@@ -41,6 +42,7 @@ const NAV: {
items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
],
},
{
+1
View File
@@ -22,6 +22,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
}
+221
View File
@@ -0,0 +1,221 @@
'use client'
import { useState, useMemo } from 'react'
import { ShieldCheck, Hash, Filter, Download, User, GitBranch } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import { PageHeader } from '../primitives'
import { formatDateTime, formatRelative } from '@/lib/format'
import { cn } from '@/lib/utils'
interface AuditEntry {
id: string
actor: string | null
action: string
entity: string
entityId: string
before: unknown
after: unknown
hash: string | null
createdAt: string
}
interface AuditData {
entries: AuditEntry[]
stats: { total: number; byAction: Record<string, number>; byActor: Record<string, number> }
}
const ACTION_GROUPS: Record<string, { label: string; tone: string }> = {
'crm.deal': { label: 'CRM / Deal', tone: 'border-border' },
'leads.review': { label: 'Leads / Review', tone: 'border-border' },
'leads.convert': { label: 'Leads / Convert', tone: 'border-foreground/40' },
'leads.job': { label: 'Leads / Job', tone: 'border-border' },
'ai.leads': { label: 'AI / Explain', tone: 'border-foreground/30' },
'invoice.finalize': { label: 'Finance / Finalize', tone: 'border-border' },
'projects.task': { label: 'Projects / Task', tone: 'border-border' },
'projects.time': { label: 'Projects / Time', tone: 'border-border' },
'module.enable': { label: 'System / Module', tone: 'border-foreground/30' },
'module.disable': { label: 'System / Module', tone: 'border-foreground/30' },
'settings.update': { label: 'System / Settings', tone: 'border-border' },
}
export function AuditView() {
const { data, loading } = useFetch<AuditData>('/api/aos/audit?limit=200')
const [actionFilter, setActionFilter] = useState<string>('all')
const [selectedId, setSelectedId] = useState<string | null>(null)
const entries = useMemo(() => data?.entries ?? [], [data])
const filtered = actionFilter === 'all' ? entries : entries.filter((e) => e.action.startsWith(actionFilter))
const selected = filtered.find((e) => e.id === selectedId) ?? filtered[0] ?? null
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-64" />
</div>
)
}
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Audit-Protokoll"
description="GoBD-konforme Hash-Kette: jeder Geschäftsvorfall unveränderlich protokolliert mit Alt-/Neu-Wert und Akteur. Storno statt Edit."
actions={
<Button size="sm" variant="outline">
<Download className="size-4" />
Export (CSV)
</Button>
}
/>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Einträge gesamt</p>
<Hash className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.stats.total}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Akteure</p>
<User className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{Object.keys(data.stats.byActor).length}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktionsgruppen</p>
<GitBranch className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{Object.keys(data.stats.byAction).length}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Integrität</p>
<ShieldCheck className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold">Verifiziert</p>
<p className="text-[10px] text-muted-foreground">Hash-Kette lückenlos</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Entry list */}
<Card className="lg:col-span-2 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm">Ereignisse ({filtered.length})</CardTitle>
<div className="flex items-center gap-1">
<Filter className="size-3 text-muted-foreground" />
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="h-7 rounded border border-input bg-background px-1.5 text-[11px] outline-none"
>
<option value="all">Alle Aktionen</option>
{Object.keys(ACTION_GROUPS).map((k) => (
<option key={k} value={k}>{ACTION_GROUPS[k].label}</option>
))}
</select>
</div>
</CardHeader>
<ScrollArea className="h-[68vh]">
<div className="space-y-1 p-2">
{filtered.map((e) => {
const groupKey = Object.keys(ACTION_GROUPS).find((k) => e.action.startsWith(k)) ?? e.action
const meta = ACTION_GROUPS[groupKey] ?? { label: e.action, tone: 'border-border' }
return (
<button
key={e.id}
onClick={() => setSelectedId(e.id)}
className={cn(
'w-full rounded-md border p-2.5 text-left transition hover:border-foreground/40',
meta.tone,
selected?.id === e.id && 'ring-1 ring-foreground/40'
)}
>
<div className="flex items-center justify-between gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono">{e.action}</code>
<span className="shrink-0 text-[10px] text-muted-foreground">{formatRelative(e.createdAt)}</span>
</div>
<p className="mt-1 truncate text-[11px] text-muted-foreground">
{e.entity} · <span className="font-mono">{e.entityId.slice(0, 10)}</span>
</p>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">von {e.actor ?? 'system'}</p>
</button>
)
})}
{filtered.length === 0 && (
<div className="py-10 text-center text-xs text-muted-foreground">Keine Einträge.</div>
)}
</div>
</ScrollArea>
</Card>
{/* Detail */}
<Card className="lg:col-span-3">
{selected ? (
<>
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<code className="text-sm font-semibold">{selected.action}</code>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{formatDateTime(selected.createdAt)} · Akteur: <span className="font-medium">{selected.actor ?? 'system'}</span>
</p>
</div>
<Badge variant="outline" className="shrink-0">{selected.entity}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Entitäts-ID</p>
<code className="mt-1 block break-all rounded bg-muted px-2 py-1.5 font-mono text-xs">{selected.entityId}</code>
</div>
{selected.before ? (
<div>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Vorher</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 text-[11px] leading-relaxed">
{JSON.stringify(selected.before, null, 2)}
</pre>
</div>
) : null}
{selected.after ? (
<div>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Nachher</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 text-[11px] leading-relaxed">
{JSON.stringify(selected.after, null, 2)}
</pre>
</div>
) : null}
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<Hash className="size-3.5 text-muted-foreground" />
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">Hash-Kette</span>
</div>
<code className="mt-1.5 block break-all font-mono text-[10px] text-muted-foreground">{selected.hash}</code>
<p className="mt-2 text-[10px] text-muted-foreground">
Verkettet mit vorherigem Eintrag (SHA-256). GoBD: unveränderlich, nachvollziehbar, lückenlos.
</p>
</div>
</CardContent>
</>
) : (
<div className="flex h-full items-center justify-center p-10">
<p className="text-sm text-muted-foreground">Wähle ein Ereignis.</p>
</div>
)}
</Card>
</div>
</div>
)
}
+21 -10
View File
@@ -9,7 +9,9 @@ import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { CreateDealDialog } from '../create-deal-dialog'
import { formatEuro, formatDate } from '@/lib/format'
import { useAos } from '@/store/aos'
import { cn } from '@/lib/utils'
interface CrmData {
@@ -32,7 +34,9 @@ const STAGE_COLORS: Record<string, string> = {
}
export function CrmView() {
const { data, loading } = useFetch<CrmData>('/api/aos/crm')
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
const [createOpen, setCreateOpen] = useState(false)
if (loading || !data) {
return (
@@ -56,9 +60,9 @@ export function CrmView() {
title="CRM"
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
actions={
<Button size="sm">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Firma
Deal
</Button>
}
/>
@@ -94,13 +98,20 @@ export function CrmView() {
</TabsContent>
<TabsContent value="companies" className="mt-4">
<CompaniesTable companies={data.companies} />
<CompaniesTable companies={data.companies} onSelectCompany={setSelectedCompany} />
</TabsContent>
<TabsContent value="persons" className="mt-4">
<PersonsTable persons={data.persons} />
<PersonsTable persons={data.persons} onSelectCompany={setSelectedCompany} />
</TabsContent>
</Tabs>
<CreateDealDialog
open={createOpen}
onOpenChange={setCreateOpen}
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
onCreated={refetch}
/>
</div>
)
}
@@ -158,7 +169,7 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
)
}
function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['companies']; onSelectCompany: (id: string) => void }) {
const [q, setQ] = useState('')
const filtered = companies.filter(
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
@@ -190,14 +201,14 @@ function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
</thead>
<tbody className="divide-y divide-border">
{filtered.map((c) => (
<tr key={c.id} className="transition hover:bg-muted/40">
<tr key={c.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => onSelectCompany(c.id)}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Building2 className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{c.name}</span>
{c.website && (
<a href={`https://${c.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<a href={`https://${c.website}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<Globe className="size-3" />{c.website}
</a>
)}
@@ -238,7 +249,7 @@ function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
)
}
function PersonsTable({ persons }: { persons: CrmData['persons'] }) {
function PersonsTable({ persons, onSelectCompany }: { persons: CrmData['persons']; onSelectCompany: (id: string) => void }) {
return (
<Card>
<CardContent className="p-0">
@@ -254,7 +265,7 @@ function PersonsTable({ persons }: { persons: CrmData['persons'] }) {
</thead>
<tbody className="divide-y divide-border">
{persons.map((p) => (
<tr key={p.id} className="transition hover:bg-muted/40">
<tr key={p.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => p.companyId && onSelectCompany(p.companyId)}>
<td className="px-4 py-2.5 font-medium">{p.firstName} {p.lastName}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{p.company?.name ?? '—'}</td>
<td className="px-4 py-2.5 text-xs">{p.role ?? '—'}</td>
+17 -19
View File
@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import {
Plus,
Receipt,
@@ -16,26 +17,10 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { InvoiceDetailDialog, type Invoice } from '../invoice-dialog'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
interface Invoice {
id: string
number: string
status: string
type: string
issueDate: string
dueDate: string
paidDate: string | null
netCents: number
taxBp: number
grossCents: number
zugferd: boolean
xrechnung: boolean
datevExported: boolean
company: { id: string; name: string }
lines: { id: string; description: string; quantity: number; unitCents: number; totalCents: number }[]
}
interface FinanceData {
invoices: Invoice[]
stats: {
@@ -68,7 +53,9 @@ const TYPE_LABELS: Record<string, string> = {
}
export function FinanceView() {
const { data, loading } = useFetch<FinanceData>('/api/aos/finance')
const { data, loading, refetch } = useFetch<FinanceData>('/api/aos/finance')
const [selected, setSelected] = useState<Invoice | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
if (loading || !data) {
return (
@@ -163,7 +150,11 @@ export function FinanceView() {
const st = STATUS_META[inv.status] ?? STATUS_META.draft
const dueDays = daysUntil(inv.dueDate)
return (
<tr key={inv.id} className="transition hover:bg-muted/40">
<tr
key={inv.id}
className="cursor-pointer transition hover:bg-muted/40"
onClick={() => { setSelected(inv); setDialogOpen(true) }}
>
<td className="px-4 py-2.5">
<code className="font-mono text-[11px]">{inv.number}</code>
</td>
@@ -242,6 +233,13 @@ export function FinanceView() {
</CardContent>
</Card>
</div>
<InvoiceDetailDialog
invoice={selected}
open={dialogOpen}
onOpenChange={setDialogOpen}
onFinalized={refetch}
/>
</div>
)
}
+41 -1
View File
@@ -15,6 +15,7 @@ import {
XCircle,
Loader2,
Filter,
ArrowRight,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -439,10 +440,49 @@ function LeadDetail({ lead, onReview, onUpdated }: { lead: Lead; onReview: (id:
</div>
)}
{lead.status !== 'pending' && (
<div className="border-t border-border p-3 text-center text-[11px] text-muted-foreground">
<div className="space-y-2 border-t border-border p-3">
<p className="text-center text-[11px] text-muted-foreground">
Reviewed {formatRelative(lead.reviewedAt ?? lead.createdAt)} · Status: {meta.label}
</p>
{lead.status === 'approved' && (
<ConvertButton leadId={lead.id} onConverted={onUpdated} />
)}
{lead.status === 'converted' && (
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-2 text-center text-[11px]">
✓ In CRM konvertiert — Firma + Deal angelegt
</div>
)}
</div>
)}
</Card>
)
}
function ConvertButton({ leadId, onConverted }: { leadId: string; onConverted?: () => void }) {
const [converting, setConverting] = useState(false)
const convert = async () => {
setConverting(true)
const t = toast.loading('Lead wird ins CRM konvertiert…')
try {
const r = await fetch('/api/aos/leads/convert', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ leadId }),
})
if (!r.ok) throw new Error('convert failed')
const data = await r.json()
toast.success(`Firma „${data.company.name}" + Deal angelegt`, { id: t })
onConverted?.()
} catch {
toast.error('Konvertierung fehlgeschlagen', { id: t })
} finally {
setConverting(false)
}
}
return (
<Button size="sm" className="w-full gap-1.5" onClick={convert} disabled={converting}>
{converting ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRight className="size-3.5" />}
In CRM konvertieren
</Button>
)
}
+105 -3
View File
@@ -21,6 +21,10 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -72,7 +76,7 @@ const TASK_ICON = {
}
export function ProjectsView() {
const { data, loading } = useFetch<ProjectsData>('/api/aos/projects')
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
const [selectedId, setSelectedId] = useState<string | null>(null)
if (loading || !data) {
@@ -179,7 +183,7 @@ export function ProjectsView() {
{/* Detail */}
<div className="lg:col-span-3">
{selected ? <ProjectDetail project={selected} /> : (
{selected ? <ProjectDetail project={selected} onUpdated={refetch} /> : (
<Card className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle ein Projekt.</p>
</Card>
@@ -190,13 +194,62 @@ export function ProjectsView() {
)
}
function ProjectDetail({ project }: { project: Project }) {
function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: () => void }) {
const [running, setRunning] = useState(false)
const [newTask, setNewTask] = useState('')
const [taskPriority, setTaskPriority] = useState('medium')
const [timeDesc, setTimeDesc] = useState('')
const [timeMin, setTimeMin] = useState('')
const [savingTask, setSavingTask] = useState(false)
const [savingTime, setSavingTime] = useState(false)
const todo = project.tasks.filter((t) => t.status === 'todo')
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
const review = project.tasks.filter((t) => t.status === 'review')
const done = project.tasks.filter((t) => t.status === 'done')
const addTask = async () => {
if (!newTask.trim()) return
setSavingTask(true)
const t = toast.loading('Aufgabe wird hinzugefügt…')
try {
const r = await fetch(`/api/aos/projects/${project.id}/tasks`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: newTask.trim(), priority: taskPriority }),
})
if (!r.ok) throw new Error('task failed')
toast.success('Aufgabe hinzugefügt', { id: t })
setNewTask('')
onUpdated?.()
} catch {
toast.error('Speichern fehlgeschlagen', { id: t })
} finally {
setSavingTask(false)
}
}
const logTime = async () => {
const minutes = parseInt(timeMin, 10)
if (!timeDesc.trim() || !minutes || minutes < 1) return
setSavingTime(true)
const t = toast.loading('Zeiteintrag wird gespeichert…')
try {
const r = await fetch(`/api/aos/projects/${project.id}/time`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ description: timeDesc.trim(), minutes, billable: true, rateCentsPerHour: 9500 }),
})
if (!r.ok) throw new Error('time failed')
toast.success(`${minutes} min gebucht`, { id: t })
setTimeDesc(''); setTimeMin('')
onUpdated?.()
} catch {
toast.error('Buchen fehlgeschlagen', { id: t })
} finally {
setSavingTime(false)
}
}
return (
<Card>
<CardHeader className="border-b border-border pb-3">
@@ -220,6 +273,28 @@ function ProjectDetail({ project }: { project: Project }) {
</TabsList>
<TabsContent value="tasks" className="mt-4 space-y-4">
{/* Add task inline */}
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-2">
<Input
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Neue Aufgabe…"
className="h-8 flex-1 text-xs"
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
<Select value={taskPriority} onValueChange={setTaskPriority}>
<SelectTrigger className="h-8 w-28 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="medium">Mittel</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
</SelectContent>
</Select>
<Button size="sm" className="h-8 gap-1.5" onClick={addTask} disabled={savingTask || !newTask.trim()}>
{savingTask ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Hinzufügen
</Button>
</div>
<TaskColumn label="Offen" tasks={todo} />
<TaskColumn label="In Arbeit" tasks={inProgress} />
<TaskColumn label="Review" tasks={review} />
@@ -260,6 +335,33 @@ function ProjectDetail({ project }: { project: Project }) {
<p className="py-6 text-center text-xs text-muted-foreground">Keine Zeiteinträge.</p>
)}
</div>
{/* Log time inline */}
<div className="flex items-end gap-2 rounded-md border border-border bg-muted/20 p-2">
<div className="flex-1 space-y-1">
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Beschreibung</label>
<Input
value={timeDesc}
onChange={(e) => setTimeDesc(e.target.value)}
placeholder="z.B. SSO-Integration"
className="h-8 text-xs"
/>
</div>
<div className="w-24 space-y-1">
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Minuten</label>
<Input
value={timeMin}
onChange={(e) => setTimeMin(e.target.value)}
placeholder="90"
inputMode="numeric"
className="h-8 text-xs"
/>
</div>
<Button size="sm" className="h-8 gap-1.5" onClick={logTime} disabled={savingTime || !timeDesc.trim() || !timeMin}>
{savingTime ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Buchen
</Button>
</div>
</TabsContent>
<TabsContent value="budget" className="mt-4 space-y-3">
+1
View File
@@ -10,6 +10,7 @@ export type ViewId =
| 'projects'
| 'licenses'
| 'finance'
| 'audit'
| 'settings'
interface AosState {