Convert Server from submodule to normal files

This commit is contained in:
2026-01-18 17:50:49 +01:00
parent 86d2191a25
commit 0012a10249
52 changed files with 11975 additions and 1 deletions

View File

@@ -0,0 +1,656 @@
"use client";
import React, { useState, useEffect } from "react";
import { IconPlus, IconChevronDown, IconX, IconRefresh } from "@tabler/icons-react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "../lib/utils";
import { useHashRoute } from "../lib/routing";
import {
setActiveAccountId,
getAccountDisplayName,
} from "../services/accountService";
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService";
import { getAuthUser } from "../lib/appwrite";
import { parseEbayAccount } from "../services/ebayParserService";
import { DataTable } from "../components/dashboard/ui/DataTable";
export const AccountsPage = () => {
const { navigate } = useHashRoute();
const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(true);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showAddForm, setShowAddForm] = useState(false);
const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState("");
const [formLoading, setFormLoading] = useState(false);
// Parse-State für Zwei-Phasen-Flow
const [parsedData, setParsedData] = useState(null);
const [parsing, setParsing] = useState(false);
const [parsingError, setParsingError] = useState("");
// Refresh-State pro Account
const [refreshingAccountId, setRefreshingAccountId] = useState(null);
const [refreshToast, setRefreshToast] = useState({ show: false, message: "", type: "success" });
// Form-Felder (nur noch URL)
const [formData, setFormData] = useState({
account_url: "",
});
// Accounts laden
useEffect(() => {
loadAccounts();
}, []);
async function loadAccounts() {
setLoading(true);
try {
const authUser = await getAuthUser();
if (!authUser) {
setAccounts([]);
return;
}
const loadedAccounts = await fetchManagedAccounts(authUser.$id);
setAccounts(loadedAccounts);
} catch (e) {
console.error("Fehler beim Laden der Accounts:", e);
setAccounts([]);
} finally {
setLoading(false);
}
}
const handleSelectAccount = (account) => {
const accountId = account.$id || account.id;
setActiveAccountId(accountId);
// Navigiere zurück zum Dashboard
navigate("/");
};
const handleRefreshAccount = async (account) => {
const accountId = account.$id || account.id;
const accountUrl = account.account_url;
if (!accountUrl) {
setRefreshToast({ show: true, message: "Account hat keine URL zum Aktualisieren.", type: "error" });
setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000);
return;
}
setRefreshingAccountId(accountId);
try {
// URL erneut parsen
const parsedData = await parseEbayAccount(accountUrl);
// Account in DB aktualisieren
// WICHTIG: Nur Felder setzen, die nicht leer sind und sich geändert haben
// Leere account_platform_account_id würde Unique-Index-Konflikte verursachen
const updatePayload = {};
// Nur market setzen, wenn nicht leer
if (parsedData.market && parsedData.market.trim()) {
updatePayload.account_platform_market = parsedData.market;
}
// Nur sellerId setzen, wenn nicht leer (verhindert Unique-Index-Konflikte mit leerem String)
if (parsedData.sellerId && parsedData.sellerId.trim()) {
updatePayload.account_platform_account_id = parsedData.sellerId;
}
// Shop-Name und account_sells können auch leer sein (optional)
updatePayload.account_shop_name = parsedData.shopName || null;
updatePayload.account_sells = parsedData.stats?.itemsSold ?? null;
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'AccountsPage.jsx:93',message:'handleRefreshAccount: update payload',data:{payload:updatePayload},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
// account_status wird weggelassen (wie beim Erstellen)
// Grund: Schema-Konflikt - Enum-Feld akzeptiert weder String noch Array im Update
// TODO: Schema in Appwrite prüfen und korrigieren, dann account_status wieder hinzufügen
await updateManagedAccount(accountId, updatePayload);
// Accounts-Liste neu laden (in-place Update)
await loadAccounts();
// Success-Toast
setRefreshToast({ show: true, message: "Account aktualisiert", type: "success" });
setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000);
} catch (e) {
console.error("Fehler beim Aktualisieren des Accounts:", e);
let errorMessage = "Update fehlgeschlagen";
if (e.message?.includes("Parsing") || e.message?.includes("URL")) {
errorMessage = "Parsing fehlgeschlagen";
}
setRefreshToast({ show: true, message: errorMessage, type: "error" });
setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000);
} finally {
setRefreshingAccountId(null);
}
};
const handleAddAccount = () => {
setShowAddForm(true);
setFormError("");
setFormSuccess("");
};
const handleCloseForm = () => {
setShowAddForm(false);
setFormError("");
setFormSuccess("");
setParsedData(null);
setParsingError("");
// Form zurücksetzen
setFormData({
account_url: "",
});
};
const handleFormChange = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear errors beim Eingeben
if (formError) setFormError("");
if (parsingError) setParsingError("");
};
const handleParseUrl = async () => {
const url = formData.account_url?.trim();
if (!url) {
setParsingError("Bitte gib eine eBay-URL ein.");
return;
}
setParsing(true);
setParsingError("");
setFormError("");
try {
const result = await parseEbayAccount(url);
setParsedData(result);
setParsingError("");
} catch (e) {
setParsingError(e.message || "Bitte gib eine gültige eBay-URL ein.");
setParsedData(null);
} finally {
setParsing(false);
}
};
const handleFormSubmit = async (e) => {
e.preventDefault();
// Wenn noch nicht geparst, zuerst parsen
if (!parsedData) {
await handleParseUrl();
return;
}
// Save-Phase: Account in DB speichern
setFormError("");
setFormSuccess("");
setFormLoading(true);
try {
const authUser = await getAuthUser();
if (!authUser) {
setFormError("Nicht eingeloggt. Bitte neu anmelden.");
return;
}
// Payload aus parsedData zusammenstellen
const accountSellsValue = parsedData.stats?.itemsSold ?? null;
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'AccountsPage.jsx:193',message:'handleFormSubmit: parsedData before save',data:{hasStats:!!parsedData.stats,itemsSold:parsedData.stats?.itemsSold,accountSellsValue},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
const newAccount = await createManagedAccount(authUser.$id, {
account_url: formData.account_url.trim(),
account_platform_account_id: parsedData.sellerId,
account_platform_market: parsedData.market,
account_shop_name: parsedData.shopName,
account_sells: accountSellsValue,
// account_status wird nicht mehr gesendet (optional, Schema-Problem in Appwrite)
});
// Erfolg: Liste refreshen
await loadAccounts();
// Wenn dies das erste Account ist, setze es als aktiv
if (accounts.length === 0) {
setActiveAccountId(newAccount.$id);
}
setFormSuccess("Account erfolgreich erstellt!");
setTimeout(() => {
handleCloseForm();
}, 1500);
} catch (e) {
// Fehlerbehandlung
const errorMessage =
e.message || "Fehler beim Erstellen des Accounts. Bitte versuche es erneut.";
setFormError(errorMessage);
} finally {
setFormLoading(false);
}
};
// Spalten für die Tabelle
const columns = [
"Account Name",
"Platform",
"Platform Account ID",
"Market",
"Account URL",
"Status",
"Last Scan",
...(showAdvanced ? ["Owner User ID"] : []),
"Action",
];
const renderCell = (col, row) => {
if (col === "Action") {
const accountId = row.$id || row.id;
const isRefreshing = refreshingAccountId === accountId;
return (
<div className="flex items-center gap-2">
<button
onClick={() => handleRefreshAccount(row)}
disabled={isRefreshing}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
title="Account aktualisieren"
>
<IconRefresh className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")} />
Refresh
</button>
<button
onClick={() => handleSelectAccount(row)}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]"
>
Select
</button>
</div>
);
}
if (col === "Account Name") {
return getAccountDisplayName(row) || "-";
}
if (col === "Platform") {
return row.account_platform || "-";
}
if (col === "Platform Account ID") {
return row.account_platform_account_id || "-";
}
if (col === "Market") {
return row.account_platform_market || "-";
}
if (col === "Account URL") {
const url = row.account_url;
if (!url) return "-";
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline dark:text-blue-400"
>
{url.length > 40 ? `${url.substring(0, 40)}...` : url}
</a>
);
}
if (col === "Status") {
return row.account_status || "-";
}
if (col === "Last Scan") {
const lastScan = row.account_last_scan_at;
if (!lastScan) return "-";
try {
const date = new Date(lastScan);
return date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch (e) {
return "-";
}
}
if (col === "Owner User ID") {
return row.account_owner_user_id || "-";
}
return "-";
};
return (
<div className="flex flex-1">
<div className="flex h-full w-full flex-1 flex-col gap-4 rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-900">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="m-0 text-2xl font-semibold tracking-wide text-[var(--text)]">
Accounts
</h1>
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
Verwalte deine Plattform-Accounts
</p>
</div>
<button
onClick={handleAddAccount}
className="flex items-center gap-2 rounded-xl border border-[var(--line)] bg-white/3 px-4 py-2.5 text-xs font-medium text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]"
>
<IconPlus className="h-4 w-4" />
Add Account
</button>
</div>
{/* Hilfe-Panel */}
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background:
"radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<h2 className="mb-3 text-sm font-semibold text-[var(--text)]">
Account hinzufügen
</h2>
<div className="grid gap-3 text-xs text-[var(--muted)]">
<div>
<span className="font-medium text-[var(--text)]">
eBay Account URL <span className="text-red-500">(Pflichtfeld)</span>
</span>
: Gib einfach die eBay-URL zum Verkäuferprofil oder Shop ein. Alle weiteren Informationen (Market, Seller ID, Shop Name) werden automatisch erkannt.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Market (Auto)
</span>
: Wird automatisch aus der URL extrahiert (z.B. DE, US, UK). Du musst nichts eingeben.
</div>
<div>
<span className="font-medium text-[var(--text)]">
eBay Seller ID (Auto)
</span>
: Wird automatisch erkannt. Dies ist die eindeutige Verkäufer-Kennung von eBay und verhindert Duplikate.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Shop Name (Auto)
</span>
: Öffentlich sichtbarer Name des Shops. Wird automatisch aus der URL/Seite extrahiert.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Status (Auto)
</span>
: Wird automatisch auf "active" gesetzt. Du musst nichts eingeben.
</div>
</div>
<div className="mt-4 rounded-lg border border-[var(--line)] bg-white/2 p-3 text-xs text-[var(--muted)]">
<span className="font-medium text-[var(--text)]">So funktioniert's:</span>{" "}
Gib einfach die eBay-URL ein und klicke auf "Account hinzufügen". Das System liest alle notwendigen Informationen automatisch aus. Du musst keine technischen Felder manuell ausfüllen.
</div>
{/* Advanced Toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="mt-4 flex items-center gap-2 text-xs text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
<IconChevronDown
className={cn(
"h-4 w-4 transition-transform",
showAdvanced && "rotate-180"
)}
/>
{showAdvanced ? "Weniger anzeigen" : "Erweitert anzeigen"}
</button>
</div>
</div>
{/* Toast Notification */}
<AnimatePresence>
{refreshToast.show && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
"fixed top-4 right-4 z-50 rounded-lg px-4 py-3 text-sm shadow-lg",
refreshToast.type === "success"
? "bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
: "bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400"
)}
>
{refreshToast.message}
</motion.div>
)}
</AnimatePresence>
{/* Tabelle */}
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background:
"radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
{loading ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
Loading accounts...
</div>
) : (
<DataTable columns={columns} data={accounts} renderCell={renderCell} />
)}
</div>
</div>
{/* Add Account Form Modal */}
<AnimatePresence>
{showAddForm && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={handleCloseForm}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-2xl rounded-2xl border border-[var(--line)] bg-white p-6 shadow-xl dark:bg-neutral-800"
>
{/* Close Button */}
<button
onClick={handleCloseForm}
className="absolute right-4 top-4 rounded-lg p-1.5 text-[var(--muted)] transition-colors hover:bg-neutral-100 hover:text-[var(--text)] dark:hover:bg-neutral-700"
>
<IconX className="h-5 w-5" />
</button>
{/* Form Header */}
<h2 className="mb-4 text-xl font-semibold text-[var(--text)]">
Add Account
</h2>
{/* Error/Success Messages */}
{parsingError && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{parsingError}
</div>
)}
{formError && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{formError}
</div>
)}
{formSuccess && (
<div className="mb-4 rounded-lg bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
{formSuccess}
</div>
)}
{/* Form */}
<form onSubmit={handleFormSubmit} className="space-y-4">
{/* Phase 1: URL Input */}
<div>
<label className="mb-1.5 block text-sm font-medium text-[var(--text)]">
eBay Account URL <span className="text-red-500">*</span>
</label>
<input
type="url"
value={formData.account_url}
onChange={(e) => handleFormChange("account_url", e.target.value)}
placeholder="https://www.ebay.de/usr/..."
required
disabled={parsing || formLoading}
className="w-full rounded-lg border border-[var(--line)] bg-white px-3 py-2 text-sm text-[var(--text)] outline-none transition-colors focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-neutral-900"
/>
<p className="mt-1.5 text-xs text-[var(--muted)]">
Füge den Link zum eBay-Verkäuferprofil oder Shop ein. Wir lesen die Account-Daten automatisch aus.
</p>
</div>
{/* Phase 2: Preview (wenn parsedData vorhanden) */}
{parsedData && (
<div className="space-y-4 rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-700 dark:bg-neutral-800/50">
<h3 className="text-sm font-semibold text-[var(--text)]">
Account-Informationen (automatisch erkannt)
</h3>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Market (Auto)
</label>
<input
type="text"
value={parsedData.market}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Automatisch erkannter Marktplatz (z.B. DE oder US).
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
eBay Seller ID (Auto)
</label>
<input
type="text"
value={parsedData.sellerId}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Eindeutige Verkäufer-ID von eBay. Wird für Abgleich und Duplikat-Erkennung verwendet.
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Shop Name (Auto)
</label>
<input
type="text"
value={parsedData.shopName}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Öffentlich sichtbarer Name des Shops auf eBay.
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Artikel verkauft (Auto)
</label>
<input
type="text"
value={parsedData.stats?.itemsSold ?? "-"}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Automatisch aus dem eBay-Profil gelesen.
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Status (Auto)
</label>
<input
type="text"
value={parsedData.status}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Interner Status. Normalerweise "active".
</p>
</div>
</div>
)}
{/* Form Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={handleCloseForm}
disabled={parsing || formLoading}
className="rounded-lg border border-[var(--line)] bg-white px-4 py-2 text-sm font-medium text-[var(--text)] transition-colors hover:bg-neutral-50 disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Cancel
</button>
{parsedData ? (
<button
type="submit"
disabled={formLoading}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{formLoading ? "Speichern..." : "Account speichern"}
</button>
) : (
<button
type="submit"
disabled={parsing || formLoading || !formData.account_url?.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{parsing ? "Lese Accountdaten aus..." : "Account hinzufügen"}
</button>
)}
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};