feat: Integrate account_metrics collection with monthly refresh calendar
- Add account_metrics collection schema script - Implement accountMetricsService with upsertAccountMetric, fetchAccountMetricsForMonth, calculateSalesBucket - Extend accountsService with getLastSuccessfulAccountMetric - Update AccountsPage to track daily metrics and display in calendar - Calculate sales difference from last successful refresh - Display refresh status and sales buckets in monthly calendar view - Remove account_refresh_events dependency (use account_metrics only)
This commit is contained in:
@@ -5,8 +5,6 @@ import {
|
||||
IconX,
|
||||
IconRefresh,
|
||||
IconChevronDown,
|
||||
IconChartBar,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -16,10 +14,12 @@ import {
|
||||
getActiveAccountId,
|
||||
getAccountDisplayName,
|
||||
} from "../services/accountService";
|
||||
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService";
|
||||
import { getAuthUser } from "../lib/appwrite";
|
||||
import { parseEbayAccount } from "../services/ebayParserService";
|
||||
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount, determineRefreshStatus, calculateDataFreshness, getLastSuccessfulAccountMetric } from "../services/accountsService";
|
||||
import { upsertAccountMetric, fetchAccountMetricsForMonth } from "../services/accountMetricsService";
|
||||
import { getAuthUser, databases, databaseId } from "../lib/appwrite";
|
||||
import { parseEbayAccount, parseViaExtensionExtended } from "../services/ebayParserService";
|
||||
import { BentoGrid, BentoGridItem } from "../components/ui/bento-grid";
|
||||
import GradientText from "../components/ui/GradientText";
|
||||
|
||||
function AccountNameCard({
|
||||
name,
|
||||
@@ -96,18 +96,28 @@ function AccountNameCard({
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight text-[var(--text)] hover:underline"
|
||||
style={{ fontSize: `${fontSize}px` }}
|
||||
className="max-w-full shrink-0 whitespace-nowrap hover:underline"
|
||||
>
|
||||
{name}
|
||||
<GradientText
|
||||
colors={['#5227FF', '#FF9FFC', '#B19EEF']}
|
||||
animationSpeed={5}
|
||||
showBorder={false}
|
||||
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight"
|
||||
style={{ fontSize: `${fontSize}px` }}
|
||||
>
|
||||
{name}
|
||||
</GradientText>
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight text-[var(--text)]"
|
||||
<GradientText
|
||||
colors={['#5227FF', '#FF9FFC', '#B19EEF']}
|
||||
animationSpeed={5}
|
||||
showBorder={false}
|
||||
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight"
|
||||
style={{ fontSize: `${fontSize}px` }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</GradientText>
|
||||
)}
|
||||
</div>
|
||||
{platformAccountId != null && platformAccountId !== "" && (
|
||||
@@ -232,6 +242,374 @@ function RangCard({ rank, className }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshActivityCard({ monthMetrics = new Map(), className }) {
|
||||
// monthMetrics: Map von date (yyyy-mm-dd) -> metric document
|
||||
|
||||
const today = new Date();
|
||||
const currentMonth = today.getMonth();
|
||||
const currentYear = today.getFullYear();
|
||||
const currentDate = today.getDate();
|
||||
|
||||
// Erstelle Kalenderstruktur für aktuellen Monat
|
||||
const getMonthCalendar = () => {
|
||||
const firstDay = new Date(currentYear, currentMonth, 1);
|
||||
const lastDay = new Date(currentYear, currentMonth + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startDayOfWeek = firstDay.getDay(); // 0 = Sonntag, 1 = Montag, etc.
|
||||
|
||||
// Wochen beginnen mit Montag (1) statt Sonntag (0)
|
||||
const adjustedStartDay = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
|
||||
|
||||
const calendar = [];
|
||||
let currentWeek = [];
|
||||
|
||||
// Leere Felder für Tage vor Monatsbeginn
|
||||
for (let i = 0; i < adjustedStartDay; i++) {
|
||||
currentWeek.push(null);
|
||||
}
|
||||
|
||||
// Tage des Monats
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(currentYear, currentMonth, day);
|
||||
currentWeek.push(date);
|
||||
|
||||
if (currentWeek.length === 7) {
|
||||
calendar.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Leere Felder für restliche Woche
|
||||
if (currentWeek.length > 0) {
|
||||
while (currentWeek.length < 7) {
|
||||
currentWeek.push(null);
|
||||
}
|
||||
calendar.push(currentWeek);
|
||||
}
|
||||
|
||||
return calendar;
|
||||
};
|
||||
|
||||
const getDayData = (date) => {
|
||||
if (!date) return { refreshStatus: null, salesBucket: null };
|
||||
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const metric = monthMetrics.get(dateStr);
|
||||
|
||||
if (!metric) {
|
||||
return {
|
||||
refreshStatus: 'not-refreshed',
|
||||
salesBucket: null
|
||||
};
|
||||
}
|
||||
|
||||
// Bestimme refreshStatus aus metric
|
||||
let refreshStatus = 'not-refreshed';
|
||||
if (metric.account_metrics_refresh_status === 'failed') {
|
||||
refreshStatus = 'failed';
|
||||
} else if (metric.account_metrics_refreshed === true && metric.account_metrics_refresh_status === 'success') {
|
||||
refreshStatus = 'refreshed';
|
||||
}
|
||||
|
||||
return {
|
||||
refreshStatus: refreshStatus,
|
||||
salesBucket: metric.account_metrics_sales_bucket || null
|
||||
};
|
||||
};
|
||||
|
||||
const getStatusColor = (status, isToday) => {
|
||||
if (isToday) {
|
||||
// Aktueller Tag: dezente Umrandung
|
||||
switch (status) {
|
||||
case 'refreshed':
|
||||
return 'bg-green-500/30 border-2 border-green-600 dark:bg-green-500/20 dark:border-green-500';
|
||||
case 'failed':
|
||||
return 'bg-red-500/20 border-2 border-red-600 dark:bg-red-500/10 dark:border-red-500';
|
||||
default:
|
||||
return 'bg-neutral-100 border-2 border-neutral-400 dark:bg-neutral-800 dark:border-neutral-500';
|
||||
}
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'refreshed':
|
||||
return 'bg-green-500/30 border border-green-500/50 dark:bg-green-500/20 dark:border-green-500/40';
|
||||
case 'failed':
|
||||
return 'bg-red-500/20 border border-red-500/30 dark:bg-red-500/10 dark:border-red-500/20';
|
||||
default:
|
||||
return 'bg-neutral-100 border border-neutral-200 dark:bg-neutral-800 dark:border-neutral-700';
|
||||
}
|
||||
};
|
||||
|
||||
const calendar = getMonthCalendar();
|
||||
const isTodayDate = (date) => {
|
||||
if (!date) return false;
|
||||
return date.getDate() === currentDate &&
|
||||
date.getMonth() === currentMonth &&
|
||||
date.getFullYear() === currentYear;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Titel oben links */}
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<div className="text-xs font-medium text-[var(--muted)]">Refresh Activity</div>
|
||||
</div>
|
||||
|
||||
{/* Wochenraster: 7 Spalten x 5 Zeilen */}
|
||||
<div className="grid h-full grid-cols-7 grid-rows-5 gap-0.5 flex-1">
|
||||
{calendar.map((week, weekIndex) => (
|
||||
<React.Fragment key={weekIndex}>
|
||||
{week.map((date, dayIndex) => {
|
||||
if (!date) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${weekIndex}-${dayIndex}`}
|
||||
className="border border-transparent"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dayData = getDayData(date);
|
||||
const isToday = isTodayDate(date);
|
||||
const dayNumber = date.getDate();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${weekIndex}-${dayIndex}`}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-sm min-h-[20px]",
|
||||
getStatusColor(dayData.refreshStatus, isToday),
|
||||
"transition-colors"
|
||||
)}
|
||||
title={`${date.toLocaleDateString('de-DE')}: ${dayData.refreshStatus === 'not-refreshed' ? 'Not refreshed' : dayData.refreshStatus === 'refreshed' ? 'Refreshed' : 'Refresh failed'}${dayData.salesBucket ? `, Sales: ${dayData.salesBucket}` : ''}`}
|
||||
>
|
||||
{/* Tag-Nummer (klein, oben links) */}
|
||||
<div className="absolute top-0.5 left-0.5 text-[8px] text-[var(--muted)] leading-none">
|
||||
{dayNumber}
|
||||
</div>
|
||||
|
||||
{/* Sales-Bucket (zentriert) */}
|
||||
{dayData.salesBucket && (
|
||||
<div className="text-[10px] font-semibold text-[var(--text)] leading-none">
|
||||
{dayData.salesBucket}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRefreshCard({
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastRefreshDate,
|
||||
streak,
|
||||
dataFreshness = 'Aging',
|
||||
className
|
||||
}) {
|
||||
const getLastRefreshText = () => {
|
||||
if (!lastRefreshDate) return "Not refreshed today";
|
||||
|
||||
const today = new Date();
|
||||
const refreshDate = new Date(lastRefreshDate);
|
||||
const diffTime = today - refreshDate;
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return "Last refresh: Today";
|
||||
if (diffDays === 1) return "Last refresh: Yesterday";
|
||||
if (diffDays < 7) return `Last refresh: ${diffDays} days ago`;
|
||||
return `Last refresh: ${refreshDate.toLocaleDateString('de-DE')}`;
|
||||
};
|
||||
|
||||
const getFreshnessLabel = () => {
|
||||
switch (dataFreshness) {
|
||||
case 'Fresh': return 'Fresh';
|
||||
case 'Outdated': return 'Outdated';
|
||||
default: return 'Aging';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Grid: 5 Zeilen x 5 Spalten */}
|
||||
<div className="grid h-full grid-cols-5 grid-rows-5 gap-0">
|
||||
{/* Zeile 1: Kontext & Bedeutung */}
|
||||
{/* Bereich A (Zeile 1, Spalten 1-5) */}
|
||||
<div className="col-span-5 row-span-1 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-[var(--muted)]">Account Refresh</div>
|
||||
<div className="text-[10px] text-[var(--muted)]">manual</div>
|
||||
</div>
|
||||
|
||||
{/* Zeile 2-3: Zentrale Aktion (Ritualkern) */}
|
||||
{/* Bereich B (Zeilen 2-3, Spalten 1-5) */}
|
||||
<div className="col-span-5 row-span-2 flex items-center justify-center">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={cn(
|
||||
"w-full h-full rounded-xl border transition-all active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]",
|
||||
"flex items-center justify-center gap-2 font-medium text-sm"
|
||||
)}
|
||||
title="Account aktualisieren"
|
||||
>
|
||||
<IconRefresh className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
||||
Refresh Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zeile 4: Tages- & Streak-Status */}
|
||||
{/* Bereich C (Zeile 4, Spalten 1-5) */}
|
||||
<div className="col-span-5 row-span-1 flex items-center justify-between text-xs text-[var(--muted)]">
|
||||
<span>{getLastRefreshText()}</span>
|
||||
{streak != null && streak > 0 && (
|
||||
<span>Streak: {streak} {streak === 1 ? 'day' : 'days'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zeile 5: Datenqualitäts-Hinweis */}
|
||||
{/* Bereich D (Zeile 5, Spalten 1-5) */}
|
||||
<div className="col-span-5 row-span-1 flex items-center">
|
||||
<div className="text-[10px] text-[var(--muted)]">
|
||||
Data freshness: {getFreshnessLabel()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SalesCard({ sales, follower, responseTime, positiveReviews, neutralReviews, negativeReviews, totalReviews, className }) {
|
||||
// Berechne Anteile für Balkendiagramm
|
||||
// WICHTIG: null = nicht verfügbar, 0 = echter Wert
|
||||
const total = (positiveReviews ?? 0) + (neutralReviews ?? 0) + (negativeReviews ?? 0);
|
||||
const positivePercent = total > 0 ? ((positiveReviews ?? 0) / total) * 100 : 0;
|
||||
const neutralPercent = total > 0 ? ((neutralReviews ?? 0) / total) * 100 : 0;
|
||||
const negativePercent = total > 0 ? ((negativeReviews ?? 0) / total) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Grid: 5 Zeilen x 10 Spalten */}
|
||||
<div className="grid h-full grid-cols-10 grid-rows-5 gap-0">
|
||||
{/* Zeile 1-2: Hero-Zone (40%) */}
|
||||
{/* Block A: Sales (Spalten 1-4, Zeilen 1-2) */}
|
||||
<div className="col-span-4 row-span-2 flex flex-col justify-center">
|
||||
<div className="text-4xl font-bold text-[var(--text)] md:text-5xl">
|
||||
{sales != null ? new Intl.NumberFormat("de-DE").format(sales) : "–"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-medium text-[var(--muted)]">Sales (gesamt)</div>
|
||||
</div>
|
||||
|
||||
{/* Block B: Follower (Spalten 5-7, Zeilen 1-2) */}
|
||||
<div className="col-span-3 row-span-2 flex flex-col justify-center">
|
||||
<div className="text-2xl font-bold text-[var(--text)] md:text-3xl">
|
||||
{follower != null ? new Intl.NumberFormat("de-DE").format(follower) : "–"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-medium text-[var(--muted)]">Follower</div>
|
||||
</div>
|
||||
|
||||
{/* Block C: Antwortzeit (Spalten 8-10, Zeile 1) */}
|
||||
<div className="col-span-3 row-span-1 flex flex-col justify-center">
|
||||
<div className="text-xs font-medium text-[var(--muted)]">
|
||||
Antwortzeit {responseTime ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeile 2, Spalten 8-10: leer */}
|
||||
<div className="col-span-3 row-span-1" />
|
||||
|
||||
{/* Zeile 3-4: Bewertungen (40%) */}
|
||||
{/* Block D: Bewertungszusammenfassung (Spalten 1-10, Zeilen 3-4) */}
|
||||
<div className="col-span-10 row-span-2 flex flex-col justify-center space-y-2">
|
||||
<div className="text-xs font-medium text-[var(--muted)]">Bewertungen der letzten 12 Monate</div>
|
||||
|
||||
{/* Balkendiagramm */}
|
||||
<div className="flex h-8 w-full items-center gap-0.5 overflow-hidden rounded border border-neutral-200 dark:border-neutral-700">
|
||||
{positivePercent > 0 && (
|
||||
<div
|
||||
className="flex h-full items-center justify-center bg-green-500/20 text-[10px] font-medium text-green-700 dark:bg-green-500/30 dark:text-green-400"
|
||||
style={{ width: `${positivePercent}%` }}
|
||||
title={`Positiv: ${positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}`}
|
||||
>
|
||||
{positivePercent > 15 && (
|
||||
<span className="truncate px-1">
|
||||
{positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{neutralPercent > 0 && (
|
||||
<div
|
||||
className="flex h-full items-center justify-center bg-gray-400/20 text-[10px] font-medium text-gray-700 dark:bg-gray-400/30 dark:text-gray-400"
|
||||
style={{ width: `${neutralPercent}%` }}
|
||||
title={`Neutral: ${neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}`}
|
||||
>
|
||||
{neutralPercent > 15 && (
|
||||
<span className="truncate px-1">
|
||||
{neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{negativePercent > 0 && (
|
||||
<div
|
||||
className="flex h-full items-center justify-center bg-red-500/20 text-[10px] font-medium text-red-700 dark:bg-red-500/30 dark:text-red-400"
|
||||
style={{ width: `${negativePercent}%` }}
|
||||
title={`Negativ: ${negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}`}
|
||||
>
|
||||
{negativePercent > 15 && (
|
||||
<span className="truncate px-1">
|
||||
{negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zahlen unter dem Balken (falls Platz) */}
|
||||
{positivePercent <= 15 && neutralPercent <= 15 && negativePercent <= 15 && (
|
||||
<div className="flex gap-4 text-[10px] text-[var(--muted)]">
|
||||
<span>Positiv: {positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}</span>
|
||||
<span>Neutral: {neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}</span>
|
||||
<span>Negativ: {negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zeile 5: Meta-Informationen (20%) */}
|
||||
{/* Block E: Gesamtbewertungen (Spalten 1-10, Zeile 5) */}
|
||||
<div className="col-span-10 row-span-1 flex items-center">
|
||||
<div className="text-xs text-[var(--muted)]">
|
||||
Gesamtbewertungen: {totalReviews != null ? new Intl.NumberFormat("de-DE").format(totalReviews) : "–"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AccountsPage = () => {
|
||||
const { navigate } = useHashRoute();
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
@@ -253,6 +631,9 @@ export const AccountsPage = () => {
|
||||
// Nur ein Account wird angezeigt; Wechsel über Dropdown
|
||||
const [displayedAccountId, setDisplayedAccountId] = useState(null);
|
||||
|
||||
// Monats-Metriken für Kalender (Map: date -> metric)
|
||||
const [monthMetrics, setMonthMetrics] = useState(new Map());
|
||||
|
||||
// Form-Felder (nur noch URL)
|
||||
const [formData, setFormData] = useState({
|
||||
account_url: "",
|
||||
@@ -278,6 +659,15 @@ export const AccountsPage = () => {
|
||||
}
|
||||
}, [accounts]);
|
||||
|
||||
// Lade Monats-Metriken wenn displayedAccountId sich ändert
|
||||
useEffect(() => {
|
||||
if (displayedAccountId) {
|
||||
loadMonthMetrics(displayedAccountId);
|
||||
} else {
|
||||
setMonthMetrics(new Map());
|
||||
}
|
||||
}, [displayedAccountId]);
|
||||
|
||||
async function loadAccounts() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -297,6 +687,25 @@ export const AccountsPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonthMetrics(accountId) {
|
||||
if (!accountId) {
|
||||
setMonthMetrics(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = today.getMonth() + 1; // 1-12
|
||||
|
||||
const metrics = await fetchAccountMetricsForMonth(accountId, year, month);
|
||||
setMonthMetrics(metrics);
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Laden der Monats-Metriken:", e);
|
||||
setMonthMetrics(new Map());
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisplayedAccountChange = (accountId) => {
|
||||
setDisplayedAccountId(accountId);
|
||||
setActiveAccountId(accountId);
|
||||
@@ -315,8 +724,16 @@ export const AccountsPage = () => {
|
||||
setRefreshingAccountId(accountId);
|
||||
|
||||
try {
|
||||
// URL erneut parsen
|
||||
const parsedData = await parseEbayAccount(accountUrl);
|
||||
// URL erweitert parsen (mit Feedback, About, Store)
|
||||
const parsedData = await parseViaExtensionExtended(accountUrl);
|
||||
|
||||
// Refresh-Status bestimmen basierend auf Partial Results
|
||||
const refreshStatus = determineRefreshStatus(parsedData.partialResults, {
|
||||
followers: parsedData.followers,
|
||||
feedbackTotal: parsedData.feedbackTotal,
|
||||
feedback12mPositive: parsedData.feedback12mPositive,
|
||||
responseTimeHours: parsedData.responseTimeHours
|
||||
});
|
||||
|
||||
// Account in DB aktualisieren
|
||||
// WICHTIG: Nur Felder setzen, die nicht leer sind und sich geändert haben
|
||||
@@ -337,6 +754,18 @@ export const AccountsPage = () => {
|
||||
updatePayload.account_shop_name = parsedData.shopName || null;
|
||||
updatePayload.account_sells = parsedData.stats?.itemsSold ?? null;
|
||||
|
||||
// Neue erweiterte Felder
|
||||
updatePayload.account_response_time_hours = parsedData.responseTimeHours ?? null;
|
||||
updatePayload.account_followers = parsedData.followers ?? null;
|
||||
updatePayload.account_feedback_total = parsedData.feedbackTotal ?? null;
|
||||
updatePayload.account_feedback_12m_positive = parsedData.feedback12mPositive ?? null;
|
||||
updatePayload.account_feedback_12m_neutral = parsedData.feedback12mNeutral ?? null;
|
||||
updatePayload.account_feedback_12m_negative = parsedData.feedback12mNegative ?? null;
|
||||
|
||||
// Refresh-Metadaten
|
||||
updatePayload.account_last_refresh_at = new Date().toISOString();
|
||||
updatePayload.account_last_refresh_status = refreshStatus;
|
||||
|
||||
// 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
|
||||
@@ -344,7 +773,76 @@ export const AccountsPage = () => {
|
||||
// Setze account_updated_at auf aktuelle Zeit
|
||||
updatePayload.account_updated_at = new Date().toISOString();
|
||||
|
||||
await updateManagedAccount(accountId, updatePayload);
|
||||
const updatedAccount = await updateManagedAccount(accountId, updatePayload);
|
||||
|
||||
// Berechne Sales-Differenz und speichere in account_metrics
|
||||
try {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd
|
||||
|
||||
// Lade letzte erfolgreiche Metrik für Sales-Differenz-Berechnung
|
||||
const lastMetric = await getLastSuccessfulAccountMetric(accountId);
|
||||
const currentSalesTotal = updatedAccount.account_sells;
|
||||
|
||||
// Berechne Sales-Differenz:
|
||||
// Da account_metrics_last_sales_total nicht erlaubt ist, verwenden wir einen Workaround:
|
||||
// - account_metrics_sales_count speichert den absoluten account_sells Wert
|
||||
// - Beim nächsten Refresh berechnen wir die Differenz: currentSalesTotal - lastMetric.sales_count
|
||||
// - Die Differenz wird in account_metrics_sales_bucket gespeichert (als Bucket-String)
|
||||
// - Für die Anzeige im Kalender verwenden wir den Bucket-String
|
||||
|
||||
let salesDifference = null;
|
||||
if (lastMetric && lastMetric.account_metrics_sales_count !== null && currentSalesTotal !== null) {
|
||||
// Letzte Metrik existiert: Berechne Differenz
|
||||
// lastMetric.account_metrics_sales_count ist der absolute Wert vom letzten Refresh
|
||||
const lastAbsoluteValue = lastMetric.account_metrics_sales_count;
|
||||
salesDifference = currentSalesTotal - lastAbsoluteValue;
|
||||
|
||||
// Stelle sicher, dass Differenz nicht negativ ist (falls account_sells zurückgesetzt wurde)
|
||||
if (salesDifference < 0) {
|
||||
salesDifference = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Speichere absoluten Wert in sales_count (für nächsten Refresh)
|
||||
// Die Differenz wird in sales_bucket gespeichert (berechnet via calculateSalesBucket)
|
||||
const salesCountToStore = currentSalesTotal;
|
||||
|
||||
// Upsert account_metrics für heute
|
||||
// WICHTIG: Da account_metrics_last_sales_total nicht erlaubt ist, speichern wir:
|
||||
// - account_metrics_sales_count: absoluten account_sells Wert (für nächsten Refresh)
|
||||
// - account_metrics_sales_bucket: Bucket basierend auf der Differenz (wenn berechenbar)
|
||||
//
|
||||
// Beim nächsten Refresh: Differenz = currentSalesTotal - lastMetric.sales_count
|
||||
|
||||
// Berechne Bucket aus Differenz (falls berechenbar)
|
||||
const { calculateSalesBucket } = await import("../services/accountMetricsService");
|
||||
const bucket = salesDifference !== null ? calculateSalesBucket(salesDifference) : null;
|
||||
|
||||
// Erstelle/Update Metrik mit absolutem Wert
|
||||
const metricDoc = await upsertAccountMetric(accountId, todayStr, {
|
||||
refreshed: true,
|
||||
refreshStatus: refreshStatus === "success" ? "success" : "failed",
|
||||
refreshedAt: new Date().toISOString(),
|
||||
salesCount: currentSalesTotal // Absoluter Wert für nächsten Refresh
|
||||
});
|
||||
|
||||
// Update sales_bucket separat (da upsertAccountMetric Bucket aus salesCount berechnet)
|
||||
if (bucket !== null && metricDoc) {
|
||||
await databases.updateDocument(
|
||||
databaseId,
|
||||
"account_metrics",
|
||||
metricDoc.$id,
|
||||
{ account_metrics_sales_bucket: bucket }
|
||||
);
|
||||
}
|
||||
|
||||
// Lade Monats-Metriken neu
|
||||
await loadMonthMetrics(accountId);
|
||||
} catch (metricsError) {
|
||||
// Nicht kritisch, nur loggen
|
||||
console.warn("Fehler beim Erstellen der Account-Metrik:", metricsError);
|
||||
}
|
||||
|
||||
// Accounts-Liste neu laden (in-place Update)
|
||||
await loadAccounts();
|
||||
@@ -362,6 +860,25 @@ export const AccountsPage = () => {
|
||||
|
||||
setRefreshToast({ show: true, message: errorMessage, type: "error" });
|
||||
setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000);
|
||||
|
||||
// Auch bei Fehler: Metrik für heute speichern (mit failed Status)
|
||||
try {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd
|
||||
|
||||
await upsertAccountMetric(accountId, todayStr, {
|
||||
refreshed: false,
|
||||
refreshStatus: "failed",
|
||||
refreshedAt: null,
|
||||
salesCount: null
|
||||
});
|
||||
|
||||
// Lade Monats-Metriken neu
|
||||
await loadMonthMetrics(accountId);
|
||||
} catch (metricsError) {
|
||||
// Nicht kritisch, nur loggen
|
||||
console.warn("Fehler beim Erstellen der Account-Metrik (failed):", metricsError);
|
||||
}
|
||||
} finally {
|
||||
setRefreshingAccountId(null);
|
||||
}
|
||||
@@ -473,10 +990,6 @@ export const AccountsPage = () => {
|
||||
const displayedAccount =
|
||||
accounts.find((a) => (a.$id || a.id) === displayedAccountId) ?? accounts[0] ?? null;
|
||||
|
||||
const BentoHeader = () => (
|
||||
<div className="flex flex-1 w-full h-full min-h-[4rem] rounded-xl border border-neutral-100 dark:border-white/[0.2] bg-neutral-50 dark:bg-neutral-800" />
|
||||
);
|
||||
|
||||
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">
|
||||
@@ -557,33 +1070,26 @@ export const AccountsPage = () => {
|
||||
className="md:col-span-1"
|
||||
/>
|
||||
<RangCard rank={undefined} className="md:col-span-1" />
|
||||
<BentoGridItem
|
||||
title="Sales"
|
||||
description={sales}
|
||||
header={<BentoHeader />}
|
||||
icon={<IconChartBar className="h-4 w-4 text-neutral-500" />}
|
||||
<SalesCard
|
||||
sales={account.account_sells ?? null}
|
||||
follower={account.account_followers ?? null}
|
||||
responseTime={account.account_response_time_hours ? `< ${account.account_response_time_hours}h` : null}
|
||||
positiveReviews={account.account_feedback_12m_positive ?? null}
|
||||
neutralReviews={account.account_feedback_12m_neutral ?? null}
|
||||
negativeReviews={account.account_feedback_12m_negative ?? null}
|
||||
totalReviews={account.account_feedback_total ?? null}
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
<BentoGridItem
|
||||
title="Actions"
|
||||
description={
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => handleRefreshAccount(account)}
|
||||
disabled={isRefreshing}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs transition-all active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
|
||||
)}
|
||||
title="Account aktualisieren"
|
||||
>
|
||||
<IconRefresh className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
header={<BentoHeader />}
|
||||
icon={<IconSettings className="h-4 w-4 text-neutral-500" />}
|
||||
<AccountRefreshCard
|
||||
onRefresh={() => handleRefreshAccount(account)}
|
||||
isRefreshing={isRefreshing}
|
||||
lastRefreshDate={account.account_last_refresh_at || null}
|
||||
streak={account.account_refresh_streak || null}
|
||||
dataFreshness={calculateDataFreshness(account.account_last_refresh_at) || 'Aging'}
|
||||
/>
|
||||
<RefreshActivityCard
|
||||
monthMetrics={monthMetrics}
|
||||
className="md:col-span-3"
|
||||
/>
|
||||
</BentoGrid>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user