lethientien commited on
Commit
e4dfa4d
·
verified ·
1 Parent(s): db91beb

Upload 28 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sử dụng Python bản ổn định
2
+ FROM python:3.10-slim
3
+
4
+ # Thiết lập thư mục làm việc
5
+ WORKDIR /app
6
+
7
+ # Copy file requirements và cài đặt
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ # Copy toàn bộ code vào container
12
+ COPY . .
13
+
14
+ # Cổng mặc định của Hugging Face là 7860
15
+ EXPOSE 7860
16
+
17
+ # Lệnh chạy ứng dụng
18
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
frontend/.env.local ADDED
@@ -0,0 +1 @@
 
 
1
+ GEMINI_API_KEY=PLACEHOLDER_API_KEY
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/App.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
3
+ import { ShopProvider } from './contexts/ShopContext';
4
+ import { AuthProvider, useAuth } from './contexts/AuthContext';
5
+ import Layout from './components/Layout';
6
+ import Dashboard from './pages/Dashboard';
7
+ import ProfitAnalytics from './pages/ProfitAnalytics';
8
+ import Orders from './pages/Orders';
9
+ import DataImport from './pages/DataImport';
10
+ import Settings from './pages/Settings';
11
+ import ShopManagement from './pages/ShopManagement';
12
+ import { Login } from './pages/Login';
13
+ import { AdminDashboard } from './pages/AdminDashboard';
14
+
15
+ const RequireAuth: React.FC<{ children: React.ReactNode; permission?: string }> = ({ children, permission }) => {
16
+ const { isAuthenticated, isLoading, hasPermission, isAdmin } = useAuth();
17
+ const location = useLocation();
18
+
19
+ if (isLoading) {
20
+ return <div className="flex h-screen items-center justify-center">Loading...</div>;
21
+ }
22
+
23
+ if (!isAuthenticated) {
24
+ return <Navigate to="/login" state={{ from: location }} replace />;
25
+ }
26
+
27
+ if (permission && !hasPermission(permission) && !isAdmin) {
28
+ return <div className="p-8 text-center text-red-600">Access Denied: You do not have permission to view this page.</div>;
29
+ }
30
+
31
+ return <>{children}</>;
32
+ };
33
+
34
+ const AppRoutes: React.FC = () => {
35
+ return (
36
+ <Routes>
37
+ <Route path="/login" element={<Login />} />
38
+
39
+ <Route path="/" element={
40
+ <RequireAuth permission="view_dashboard">
41
+ <Layout>
42
+ <Dashboard />
43
+ </Layout>
44
+ </RequireAuth>
45
+ } />
46
+
47
+ <Route path="/profit" element={
48
+ <RequireAuth permission="view_profit">
49
+ <Layout>
50
+ <ProfitAnalytics />
51
+ </Layout>
52
+ </RequireAuth>
53
+ } />
54
+
55
+ <Route path="/orders" element={
56
+ <RequireAuth permission="view_orders">
57
+ <Layout>
58
+ <Orders />
59
+ </Layout>
60
+ </RequireAuth>
61
+ } />
62
+
63
+ <Route path="/import" element={
64
+ <RequireAuth permission="view_import">
65
+ <Layout>
66
+ <DataImport />
67
+ </Layout>
68
+ </RequireAuth>
69
+ } />
70
+
71
+ <Route path="/shops" element={
72
+ <RequireAuth permission="view_shops">
73
+ <Layout>
74
+ <ShopManagement />
75
+ </Layout>
76
+ </RequireAuth>
77
+ } />
78
+
79
+ <Route path="/settings" element={
80
+ <RequireAuth permission="manage_settings">
81
+ <Layout>
82
+ <Settings />
83
+ </Layout>
84
+ </RequireAuth>
85
+ } />
86
+
87
+ <Route path="/admin" element={
88
+ <RequireAuth>
89
+ <Layout>
90
+ <AdminDashboard />
91
+ </Layout>
92
+ </RequireAuth>
93
+ } />
94
+
95
+ <Route path="*" element={<Navigate to="/" replace />} />
96
+ </Routes>
97
+ );
98
+ };
99
+
100
+ const App: React.FC = () => {
101
+ return (
102
+ <AuthProvider>
103
+ <ShopProvider>
104
+ <Router>
105
+ <AppRoutes />
106
+ </Router>
107
+ </ShopProvider>
108
+ </AuthProvider>
109
+ );
110
+ };
111
+
112
+ export default App;
frontend/README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+ <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
3
+ </div>
4
+
5
+ # Run and deploy your AI Studio app
6
+
7
+ This contains everything you need to run your app locally.
8
+
9
+ View your app in AI Studio: https://ai.studio/apps/drive/16GQbpwiomN0VwS61QXpdHLgZPLiRv7Cs
10
+
11
+ ## Run Locally
12
+
13
+ **Prerequisites:** Node.js
14
+
15
+
16
+ 1. Install dependencies:
17
+ `npm install`
18
+ 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19
+ 3. Run the app:
20
+ `npm run dev`
frontend/components/Layout.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
3
+ import ShopSelector from './ShopSelector';
4
+ import { useAuth } from '../contexts/AuthContext';
5
+
6
+ interface LayoutProps {
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ const Layout: React.FC<LayoutProps> = ({ children }) => {
11
+ const location = useLocation();
12
+ const navigate = useNavigate();
13
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
14
+ const { user, isAdmin, logout, hasPermission } = useAuth();
15
+
16
+ const handleLogout = () => {
17
+ logout();
18
+ navigate('/login');
19
+ };
20
+
21
+ const navItems = [
22
+ { name: 'Dashboard', path: '/', icon: 'dashboard', permission: 'view_dashboard' },
23
+ { name: 'Profit Analytics', path: '/profit', icon: 'bar_chart', permission: 'view_profit' },
24
+ { name: 'Orders', path: '/orders', icon: 'shopping_cart', permission: 'view_orders' },
25
+ { name: 'Data Import', path: '/import', icon: 'cloud_upload', permission: 'view_import' },
26
+ { name: 'Shops', path: '/shops', icon: 'storefront', permission: 'view_shops' },
27
+ { name: 'Settings', path: '/settings', icon: 'settings', permission: 'manage_settings' },
28
+ ];
29
+
30
+ const filteredNavItems = navItems.filter(item => hasPermission(item.permission));
31
+
32
+ if (isAdmin) {
33
+ // Admin link doesn't need a permission check because isAdmin covers it,
34
+ // but for type consistency we can add a dummy permission or just push to filteredNavItems
35
+ // since filteredNavItems is what we iterate over.
36
+ // However, navItems is defined as read-only implicitly by ts inference unless typed.
37
+ // Let's just push to filteredNavItems.
38
+ filteredNavItems.push({ name: 'Admin', path: '/admin', icon: 'admin_panel_settings', permission: 'admin' });
39
+ }
40
+
41
+ return (
42
+ <div className="flex h-screen w-full overflow-hidden bg-background-light">
43
+ {/* Sidebar */}
44
+ <aside className={`fixed inset-y-0 left-0 z-30 w-64 transform bg-white border-r border-border-light transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}>
45
+ <div className="flex h-full flex-col justify-between p-4">
46
+ <div className="flex flex-col gap-6">
47
+ {/* Logo */}
48
+ <div className="flex items-center gap-3 px-2">
49
+ <div
50
+ className="bg-gradient-to-br from-primary to-primary/70 rounded-xl size-10 shadow-lg shadow-primary/20 flex items-center justify-center"
51
+ >
52
+ <span className="material-symbols-outlined text-white">analytics</span>
53
+ </div>
54
+ <h1 className="text-text-main text-lg font-bold tracking-tight">Etsy Manager</h1>
55
+ </div>
56
+
57
+ {/* Shop Selector */}
58
+ <div className="px-1">
59
+ <ShopSelector />
60
+ </div>
61
+
62
+ {/* Navigation */}
63
+ <nav className="flex flex-col gap-1">
64
+ {filteredNavItems.map((item) => {
65
+ const isActive = location.pathname === item.path;
66
+ return (
67
+ <NavLink
68
+ key={item.path}
69
+ to={item.path}
70
+ onClick={() => setIsMobileMenuOpen(false)}
71
+ className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors group ${isActive
72
+ ? 'bg-primary/10 text-primary border border-primary/20'
73
+ : 'text-text-secondary hover:text-primary hover:bg-slate-50'
74
+ }`}
75
+ >
76
+ <span className={`material-symbols-outlined ${isActive ? 'filled' : ''}`}>{item.icon}</span>
77
+ <span className={`text-sm ${isActive ? 'font-semibold' : 'font-medium'}`}>{item.name}</span>
78
+ </NavLink>
79
+ );
80
+ })}
81
+ </nav>
82
+ </div>
83
+
84
+ {/* User Info */}
85
+ <div className="flex items-center justify-between gap-2 px-3 py-3 mt-auto rounded-xl bg-slate-50 border border-border-light">
86
+ <div className="flex items-center gap-3 overflow-hidden">
87
+ <div className="size-8 rounded-full bg-gradient-to-br from-slate-400 to-slate-300 flex items-center justify-center flex-shrink-0">
88
+ <span className="material-symbols-outlined text-white text-sm">person</span>
89
+ </div>
90
+ <div className="flex flex-col overflow-hidden">
91
+ <span className="text-sm font-medium text-text-main truncate">{user?.username || 'User'}</span>
92
+ <span className="text-xs text-text-secondary truncate capitalize">{user?.role || 'Guest'}</span>
93
+ </div>
94
+ </div>
95
+ <button
96
+ onClick={handleLogout}
97
+ className="text-gray-400 hover:text-red-500 transition-colors p-1"
98
+ title="Logout"
99
+ >
100
+ <span className="material-symbols-outlined text-xl">logout</span>
101
+ </button>
102
+ </div>
103
+ </div>
104
+ </aside>
105
+
106
+ {/* Main Content */}
107
+ <main className="flex-1 flex flex-col h-full overflow-hidden relative">
108
+ {/* Mobile Header */}
109
+ <header className="md:hidden flex items-center justify-between p-4 border-b border-border-light bg-white">
110
+ <h1 className="text-text-main font-bold">Etsy Manager</h1>
111
+ <button
112
+ onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
113
+ className="text-text-secondary material-symbols-outlined"
114
+ >
115
+ menu
116
+ </button>
117
+ </header>
118
+
119
+ {/* Content Scroll Area */}
120
+ <div className="flex-1 overflow-y-auto p-4 md:p-8">
121
+ {children}
122
+ </div>
123
+ </main>
124
+
125
+ {/* Mobile Overlay */}
126
+ {isMobileMenuOpen && (
127
+ <div
128
+ className="fixed inset-0 bg-black/50 z-20 md:hidden"
129
+ onClick={() => setIsMobileMenuOpen(false)}
130
+ ></div>
131
+ )}
132
+ </div>
133
+ );
134
+ };
135
+
136
+ export default Layout;
frontend/components/ShopSelector.tsx ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useMemo } from 'react';
2
+ import { useShop } from '../contexts/ShopContext';
3
+ import { Shop } from '../types';
4
+
5
+ interface ShopSelectorProps {
6
+ compact?: boolean;
7
+ }
8
+
9
+ const ShopSelector: React.FC<ShopSelectorProps> = ({ compact = false }) => {
10
+ const { shops, currentShop, setCurrentShop, isLoading, isAllShopsMode } = useShop();
11
+ const [isOpen, setIsOpen] = useState(false);
12
+
13
+ // Calculate total orders across all shops
14
+ const totalOrders = useMemo(() => {
15
+ return shops.reduce((sum, shop) => sum + (shop.order_count || 0), 0);
16
+ }, [shops]);
17
+
18
+ if (isLoading) {
19
+ return (
20
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 animate-pulse">
21
+ <div className="w-8 h-8 rounded-full bg-slate-200"></div>
22
+ <div className="h-4 w-24 bg-slate-200 rounded"></div>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ if (shops.length === 0) {
28
+ return (
29
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200">
30
+ <span className="material-symbols-outlined text-amber-600">warning</span>
31
+ <span className="text-sm text-amber-700">No shops configured</span>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ const handleSelect = (shop: Shop | null) => {
37
+ setCurrentShop(shop);
38
+ setIsOpen(false);
39
+ };
40
+
41
+ // Determine display text and styling based on mode
42
+ const displayName = isAllShopsMode ? 'All Shops' : (currentShop?.name || 'Select Shop');
43
+ const displayLabel = isAllShopsMode ? 'Viewing All' : 'Current Shop';
44
+
45
+ return (
46
+ <div className="relative">
47
+ <button
48
+ onClick={() => setIsOpen(!isOpen)}
49
+ className={`
50
+ flex items-center gap-2 px-3 py-2 rounded-lg
51
+ ${isAllShopsMode
52
+ ? 'bg-gradient-to-r from-purple-100 to-indigo-100 border border-purple-200 hover:border-purple-300'
53
+ : 'bg-gradient-to-r from-primary/10 to-primary/5 border border-primary/20 hover:border-primary/40'
54
+ }
55
+ transition-all duration-200 group
56
+ ${compact ? 'pr-2' : 'min-w-[200px]'}
57
+ `}
58
+ >
59
+ {/* Shop Icon */}
60
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center shadow-sm ${isAllShopsMode
61
+ ? 'bg-gradient-to-br from-purple-500 to-indigo-500'
62
+ : 'bg-gradient-to-br from-primary to-primary/70'
63
+ }`}>
64
+ <span className="material-symbols-outlined text-white text-sm">
65
+ {isAllShopsMode ? 'dashboard' : 'storefront'}
66
+ </span>
67
+ </div>
68
+
69
+ {/* Shop Name */}
70
+ {!compact && (
71
+ <div className="flex-1 text-left overflow-hidden">
72
+ <p className="text-xs text-text-secondary font-medium">{displayLabel}</p>
73
+ <p className={`text-sm font-semibold truncate ${isAllShopsMode ? 'text-purple-700' : 'text-text-main'}`}>
74
+ {displayName}
75
+ </p>
76
+ </div>
77
+ )}
78
+
79
+ {/* Dropdown Arrow */}
80
+ <span className={`material-symbols-outlined text-text-secondary transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}>
81
+ expand_more
82
+ </span>
83
+ </button>
84
+
85
+ {/* Dropdown Menu */}
86
+ {isOpen && (
87
+ <>
88
+ {/* Backdrop */}
89
+ <div
90
+ className="fixed inset-0 z-40"
91
+ onClick={() => setIsOpen(false)}
92
+ />
93
+
94
+ {/* Menu */}
95
+ <div className="absolute top-full left-0 right-0 mt-2 z-50 bg-white rounded-xl shadow-xl border border-border-light overflow-hidden min-w-[240px]">
96
+ <div className="p-2 border-b border-border-light bg-slate-50">
97
+ <p className="text-xs font-medium text-text-secondary px-2">Select View</p>
98
+ </div>
99
+
100
+ <div className="max-h-72 overflow-y-auto py-1">
101
+ {/* All Shops Option */}
102
+ <button
103
+ onClick={() => handleSelect(null)}
104
+ className={`
105
+ w-full flex items-center gap-3 px-3 py-3 text-left
106
+ transition-colors duration-150 border-b border-border-light
107
+ ${isAllShopsMode
108
+ ? 'bg-purple-50 text-purple-700'
109
+ : 'hover:bg-slate-50 text-text-main'
110
+ }
111
+ `}
112
+ >
113
+ <div className={`
114
+ w-9 h-9 rounded-full flex items-center justify-center
115
+ ${isAllShopsMode
116
+ ? 'bg-gradient-to-br from-purple-500 to-indigo-500 text-white'
117
+ : 'bg-gradient-to-br from-purple-100 to-indigo-100 text-purple-600'
118
+ }
119
+ `}>
120
+ <span className="material-symbols-outlined text-lg">dashboard</span>
121
+ </div>
122
+
123
+ <div className="flex-1 min-w-0">
124
+ <p className={`text-sm font-semibold ${isAllShopsMode ? 'text-purple-700' : 'text-text-main'}`}>
125
+ 📊 All Shops
126
+ </p>
127
+ <p className="text-xs text-text-secondary">
128
+ {shops.length} shops • {totalOrders} total orders
129
+ </p>
130
+ </div>
131
+
132
+ {isAllShopsMode && (
133
+ <span className="material-symbols-outlined text-purple-600 text-lg">check_circle</span>
134
+ )}
135
+ </button>
136
+
137
+ {/* Divider with label */}
138
+ <div className="px-3 py-2 bg-slate-50">
139
+ <p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider">Individual Shops</p>
140
+ </div>
141
+
142
+ {/* Individual Shops */}
143
+ {shops.map((shop) => (
144
+ <button
145
+ key={shop.id}
146
+ onClick={() => handleSelect(shop)}
147
+ className={`
148
+ w-full flex items-center gap-3 px-3 py-2.5 text-left
149
+ transition-colors duration-150
150
+ ${currentShop?.id === shop.id && !isAllShopsMode
151
+ ? 'bg-primary/10 text-primary'
152
+ : 'hover:bg-slate-50 text-text-main'
153
+ }
154
+ `}
155
+ >
156
+ <div className={`
157
+ w-8 h-8 rounded-full flex items-center justify-center text-sm
158
+ ${currentShop?.id === shop.id && !isAllShopsMode
159
+ ? 'bg-primary text-white'
160
+ : 'bg-slate-200 text-text-secondary'
161
+ }
162
+ `}>
163
+ <span className="material-symbols-outlined text-sm">storefront</span>
164
+ </div>
165
+
166
+ <div className="flex-1 min-w-0">
167
+ <p className={`text-sm font-medium truncate ${currentShop?.id === shop.id && !isAllShopsMode ? 'text-primary' : ''}`}>
168
+ {shop.name}
169
+ </p>
170
+ <p className="text-xs text-text-secondary">
171
+ {shop.order_count || 0} orders
172
+ </p>
173
+ </div>
174
+
175
+ {currentShop?.id === shop.id && !isAllShopsMode && (
176
+ <span className="material-symbols-outlined text-primary text-lg">check_circle</span>
177
+ )}
178
+ </button>
179
+ ))}
180
+ </div>
181
+
182
+ {/* Manage Shops Link */}
183
+ <div className="p-2 border-t border-border-light bg-slate-50">
184
+ <a
185
+ href="#/shops"
186
+ onClick={() => setIsOpen(false)}
187
+ className="flex items-center gap-2 px-2 py-1.5 text-sm text-primary hover:text-primary/80 transition-colors"
188
+ >
189
+ <span className="material-symbols-outlined text-sm">settings</span>
190
+ Manage Shops
191
+ </a>
192
+ </div>
193
+ </div>
194
+ </>
195
+ )}
196
+ </div>
197
+ );
198
+ };
199
+
200
+ export default ShopSelector;
frontend/contexts/AuthContext.tsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
2
+ import { User, LoginResponse } from '../types';
3
+ import { API_BASE_URL } from '../services/api';
4
+
5
+ interface AuthContextType {
6
+ user: User | null;
7
+ token: string | null;
8
+ login: (token: string) => Promise<void>;
9
+ logout: () => void;
10
+ isLoading: boolean;
11
+ isAdmin: boolean;
12
+ isAuthenticated: boolean;
13
+ hasPermission: (permission: string) => boolean;
14
+ }
15
+
16
+ const AuthContext = createContext<AuthContextType | undefined>(undefined);
17
+
18
+ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
19
+ const [user, setUser] = useState<User | null>(null);
20
+ const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
21
+ const [isLoading, setIsLoading] = useState<boolean>(true);
22
+
23
+ // Initial load / Token validation
24
+ useEffect(() => {
25
+ const initAuth = async () => {
26
+ const storedToken = localStorage.getItem('token');
27
+ if (storedToken) {
28
+ try {
29
+ const response = await fetch(`${API_BASE_URL}/users/me`, {
30
+ headers: {
31
+ 'Authorization': `Bearer ${storedToken}`
32
+ }
33
+ });
34
+
35
+ if (response.ok) {
36
+ const userData: User = await response.json();
37
+ setUser(userData);
38
+ setToken(storedToken);
39
+ } else {
40
+ // Token invalid or expired
41
+ logout();
42
+ }
43
+ } catch (error) {
44
+ console.error("Auth initialization failed", error);
45
+ logout();
46
+ }
47
+ }
48
+ setIsLoading(false);
49
+ };
50
+
51
+ initAuth();
52
+ }, []);
53
+
54
+ const login = async (newToken: string) => {
55
+ localStorage.setItem('token', newToken);
56
+ setToken(newToken);
57
+
58
+ // Fetch user details immediately
59
+ try {
60
+ const response = await fetch(`${API_BASE_URL}/users/me`, {
61
+ headers: {
62
+ 'Authorization': `Bearer ${newToken}`
63
+ }
64
+ });
65
+
66
+ if (response.ok) {
67
+ const userData: User = await response.json();
68
+ setUser(userData);
69
+ } else {
70
+ throw new Error("Failed to fetch user profile");
71
+ }
72
+ } catch (error) {
73
+ console.error("Login failed", error);
74
+ logout();
75
+ throw error;
76
+ }
77
+ };
78
+
79
+ const logout = () => {
80
+ localStorage.removeItem('token');
81
+ setToken(null);
82
+ setUser(null);
83
+ };
84
+
85
+ const hasPermission = (permission: string) => {
86
+ if (!user) return false;
87
+ if (user.role === 'admin') return true;
88
+ return user.permissions?.includes(permission) || false;
89
+ };
90
+
91
+ const value = {
92
+ user,
93
+ token,
94
+ login,
95
+ logout,
96
+ isLoading,
97
+ isAdmin: user?.role === 'admin',
98
+ isAuthenticated: !!user,
99
+ hasPermission
100
+ };
101
+
102
+ return (
103
+ <AuthContext.Provider value={value}>
104
+ {children}
105
+ </AuthContext.Provider>
106
+ );
107
+ };
108
+
109
+ export const useAuth = () => {
110
+ const context = useContext(AuthContext);
111
+ if (context === undefined) {
112
+ throw new Error('useAuth must be used within an AuthProvider');
113
+ }
114
+ return context;
115
+ };
frontend/contexts/ShopContext.tsx ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
2
+ import { Shop } from '../types';
3
+ import { fetchShops } from '../services/api';
4
+
5
+ interface ShopContextType {
6
+ shops: Shop[];
7
+ currentShop: Shop | null;
8
+ setCurrentShop: (shop: Shop | null) => void;
9
+ isLoading: boolean;
10
+ error: string | null;
11
+ refreshShops: () => Promise<void>;
12
+ isAllShopsMode: boolean;
13
+ }
14
+
15
+ const ShopContext = createContext<ShopContextType | undefined>(undefined);
16
+
17
+ const STORAGE_KEY = 'etsy_manager_current_shop_id';
18
+
19
+ export const ShopProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
20
+ const [shops, setShops] = useState<Shop[]>([]);
21
+ const [currentShop, setCurrentShopState] = useState<Shop | null>(null);
22
+ const [isLoading, setIsLoading] = useState(true);
23
+ const [error, setError] = useState<string | null>(null);
24
+
25
+ const loadShops = async () => {
26
+ setIsLoading(true);
27
+ setError(null);
28
+ try {
29
+ const data = await fetchShops();
30
+ setShops(data);
31
+
32
+ // Restore last selected shop from localStorage
33
+ const savedShopId = localStorage.getItem(STORAGE_KEY);
34
+ if (savedShopId) {
35
+ const savedShop = data.find(s => s.id === parseInt(savedShopId));
36
+ if (savedShop) {
37
+ setCurrentShopState(savedShop);
38
+ } else if (data.length > 0) {
39
+ // Saved shop no longer exists, select first
40
+ setCurrentShopState(data[0]);
41
+ }
42
+ } else if (data.length > 0) {
43
+ // No saved shop, select first
44
+ setCurrentShopState(data[0]);
45
+ }
46
+ } catch (err) {
47
+ setError(err instanceof Error ? err.message : 'Failed to load shops');
48
+ } finally {
49
+ setIsLoading(false);
50
+ }
51
+ };
52
+
53
+ useEffect(() => {
54
+ loadShops();
55
+ }, []);
56
+
57
+ const setCurrentShop = (shop: Shop | null) => {
58
+ setCurrentShopState(shop);
59
+ if (shop) {
60
+ localStorage.setItem(STORAGE_KEY, shop.id.toString());
61
+ } else {
62
+ localStorage.removeItem(STORAGE_KEY);
63
+ }
64
+ };
65
+
66
+ const refreshShops = async () => {
67
+ await loadShops();
68
+ };
69
+
70
+ const isAllShopsMode = currentShop === null && shops.length > 0;
71
+
72
+ return (
73
+ <ShopContext.Provider value={{
74
+ shops,
75
+ currentShop,
76
+ setCurrentShop,
77
+ isLoading,
78
+ error,
79
+ refreshShops,
80
+ isAllShopsMode
81
+ }}>
82
+ {children}
83
+ </ShopContext.Provider>
84
+ );
85
+ };
86
+
87
+ export const useShop = (): ShopContextType => {
88
+ const context = useContext(ShopContext);
89
+ if (context === undefined) {
90
+ throw new Error('useShop must be used within a ShopProvider');
91
+ }
92
+ return context;
93
+ };
94
+
95
+ export default ShopContext;
frontend/index.html ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Etsy Profit Analytics</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <link
14
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
15
+ rel="stylesheet"
16
+ />
17
+ <script src="https://cdn.tailwindcss.com"></script>
18
+ <script>
19
+ tailwind.config = {
20
+ theme: {
21
+ extend: {
22
+ colors: {
23
+ primary: "#137fec",
24
+ "primary-dark": "#0b63b8",
25
+ "background-light": "#f6f7f8",
26
+ "surface-white": "#ffffff",
27
+ "text-main": "#0f172a",
28
+ "text-secondary": "#64748b",
29
+ "border-light": "#e2e8f0",
30
+ },
31
+ fontFamily: {
32
+ sans: ["Inter", "sans-serif"],
33
+ },
34
+ },
35
+ },
36
+ };
37
+ </script>
38
+ <style>
39
+ body {
40
+ font-family: "Inter", sans-serif;
41
+ }
42
+ .material-symbols-outlined {
43
+ font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
44
+ }
45
+ .material-symbols-outlined.filled {
46
+ font-variation-settings: "FILL" 1, "wght" 400, "GRAD" 0, "opsz" 24;
47
+ }
48
+ ::-webkit-scrollbar {
49
+ width: 8px;
50
+ height: 8px;
51
+ }
52
+ ::-webkit-scrollbar-track {
53
+ background: #f1f5f9;
54
+ }
55
+ ::-webkit-scrollbar-thumb {
56
+ background: #cbd5e1;
57
+ border-radius: 4px;
58
+ }
59
+ ::-webkit-scrollbar-thumb:hover {
60
+ background: #94a3b8;
61
+ }
62
+ </style>
63
+ <script type="importmap">
64
+ {
65
+ "imports": {
66
+ "react-dom/": "https://esm.sh/react-dom@^19.2.3/",
67
+ "recharts": "https://esm.sh/recharts@^3.6.0",
68
+ "react/": "https://esm.sh/react@^19.2.3/",
69
+ "react": "https://esm.sh/react@^19.2.3",
70
+ "react-router-dom": "https://esm.sh/react-router-dom@^7.11.0"
71
+ }
72
+ }
73
+ </script>
74
+ <link rel="stylesheet" href="/index.css">
75
+ </head>
76
+ <body class="bg-background-light text-text-main antialiased overflow-hidden">
77
+ <div id="root"></div>
78
+ <script type="module" src="/index.tsx"></script>
79
+ </body>
80
+ </html>
frontend/index.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+
5
+ const rootElement = document.getElementById('root');
6
+ if (!rootElement) {
7
+ throw new Error("Could not find root element to mount to");
8
+ }
9
+
10
+ const root = ReactDOM.createRoot(rootElement);
11
+ root.render(
12
+ <React.StrictMode>
13
+ <App />
14
+ </React.StrictMode>
15
+ );
frontend/metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "Etsy Profit Analytics",
3
+ "description": "A comprehensive dashboard to track Etsy shop revenue, costs, and net profit with data import capabilities.",
4
+ "requestFramePermissions": []
5
+ }
frontend/package-lock.json ADDED
@@ -0,0 +1,2235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "etsy-profit-analytics",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "etsy-profit-analytics",
9
+ "version": "0.0.0",
10
+ "dependencies": {
11
+ "react": "^19.2.3",
12
+ "react-dom": "^19.2.3",
13
+ "react-router-dom": "^7.11.0",
14
+ "recharts": "^3.6.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.14.0",
18
+ "@vitejs/plugin-react": "^5.0.0",
19
+ "typescript": "~5.8.2",
20
+ "vite": "^6.2.0"
21
+ }
22
+ },
23
+ "node_modules/@babel/code-frame": {
24
+ "version": "7.27.1",
25
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
26
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
27
+ "dev": true,
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "@babel/helper-validator-identifier": "^7.27.1",
31
+ "js-tokens": "^4.0.0",
32
+ "picocolors": "^1.1.1"
33
+ },
34
+ "engines": {
35
+ "node": ">=6.9.0"
36
+ }
37
+ },
38
+ "node_modules/@babel/compat-data": {
39
+ "version": "7.28.5",
40
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
41
+ "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
42
+ "dev": true,
43
+ "license": "MIT",
44
+ "engines": {
45
+ "node": ">=6.9.0"
46
+ }
47
+ },
48
+ "node_modules/@babel/core": {
49
+ "version": "7.28.5",
50
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
51
+ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
52
+ "dev": true,
53
+ "license": "MIT",
54
+ "peer": true,
55
+ "dependencies": {
56
+ "@babel/code-frame": "^7.27.1",
57
+ "@babel/generator": "^7.28.5",
58
+ "@babel/helper-compilation-targets": "^7.27.2",
59
+ "@babel/helper-module-transforms": "^7.28.3",
60
+ "@babel/helpers": "^7.28.4",
61
+ "@babel/parser": "^7.28.5",
62
+ "@babel/template": "^7.27.2",
63
+ "@babel/traverse": "^7.28.5",
64
+ "@babel/types": "^7.28.5",
65
+ "@jridgewell/remapping": "^2.3.5",
66
+ "convert-source-map": "^2.0.0",
67
+ "debug": "^4.1.0",
68
+ "gensync": "^1.0.0-beta.2",
69
+ "json5": "^2.2.3",
70
+ "semver": "^6.3.1"
71
+ },
72
+ "engines": {
73
+ "node": ">=6.9.0"
74
+ },
75
+ "funding": {
76
+ "type": "opencollective",
77
+ "url": "https://opencollective.com/babel"
78
+ }
79
+ },
80
+ "node_modules/@babel/generator": {
81
+ "version": "7.28.5",
82
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
83
+ "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
84
+ "dev": true,
85
+ "license": "MIT",
86
+ "dependencies": {
87
+ "@babel/parser": "^7.28.5",
88
+ "@babel/types": "^7.28.5",
89
+ "@jridgewell/gen-mapping": "^0.3.12",
90
+ "@jridgewell/trace-mapping": "^0.3.28",
91
+ "jsesc": "^3.0.2"
92
+ },
93
+ "engines": {
94
+ "node": ">=6.9.0"
95
+ }
96
+ },
97
+ "node_modules/@babel/helper-compilation-targets": {
98
+ "version": "7.27.2",
99
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
100
+ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
101
+ "dev": true,
102
+ "license": "MIT",
103
+ "dependencies": {
104
+ "@babel/compat-data": "^7.27.2",
105
+ "@babel/helper-validator-option": "^7.27.1",
106
+ "browserslist": "^4.24.0",
107
+ "lru-cache": "^5.1.1",
108
+ "semver": "^6.3.1"
109
+ },
110
+ "engines": {
111
+ "node": ">=6.9.0"
112
+ }
113
+ },
114
+ "node_modules/@babel/helper-globals": {
115
+ "version": "7.28.0",
116
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
117
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
118
+ "dev": true,
119
+ "license": "MIT",
120
+ "engines": {
121
+ "node": ">=6.9.0"
122
+ }
123
+ },
124
+ "node_modules/@babel/helper-module-imports": {
125
+ "version": "7.27.1",
126
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
127
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
128
+ "dev": true,
129
+ "license": "MIT",
130
+ "dependencies": {
131
+ "@babel/traverse": "^7.27.1",
132
+ "@babel/types": "^7.27.1"
133
+ },
134
+ "engines": {
135
+ "node": ">=6.9.0"
136
+ }
137
+ },
138
+ "node_modules/@babel/helper-module-transforms": {
139
+ "version": "7.28.3",
140
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
141
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
142
+ "dev": true,
143
+ "license": "MIT",
144
+ "dependencies": {
145
+ "@babel/helper-module-imports": "^7.27.1",
146
+ "@babel/helper-validator-identifier": "^7.27.1",
147
+ "@babel/traverse": "^7.28.3"
148
+ },
149
+ "engines": {
150
+ "node": ">=6.9.0"
151
+ },
152
+ "peerDependencies": {
153
+ "@babel/core": "^7.0.0"
154
+ }
155
+ },
156
+ "node_modules/@babel/helper-plugin-utils": {
157
+ "version": "7.27.1",
158
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
159
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
160
+ "dev": true,
161
+ "license": "MIT",
162
+ "engines": {
163
+ "node": ">=6.9.0"
164
+ }
165
+ },
166
+ "node_modules/@babel/helper-string-parser": {
167
+ "version": "7.27.1",
168
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
169
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
170
+ "dev": true,
171
+ "license": "MIT",
172
+ "engines": {
173
+ "node": ">=6.9.0"
174
+ }
175
+ },
176
+ "node_modules/@babel/helper-validator-identifier": {
177
+ "version": "7.28.5",
178
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
179
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
180
+ "dev": true,
181
+ "license": "MIT",
182
+ "engines": {
183
+ "node": ">=6.9.0"
184
+ }
185
+ },
186
+ "node_modules/@babel/helper-validator-option": {
187
+ "version": "7.27.1",
188
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
189
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
190
+ "dev": true,
191
+ "license": "MIT",
192
+ "engines": {
193
+ "node": ">=6.9.0"
194
+ }
195
+ },
196
+ "node_modules/@babel/helpers": {
197
+ "version": "7.28.4",
198
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
199
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
200
+ "dev": true,
201
+ "license": "MIT",
202
+ "dependencies": {
203
+ "@babel/template": "^7.27.2",
204
+ "@babel/types": "^7.28.4"
205
+ },
206
+ "engines": {
207
+ "node": ">=6.9.0"
208
+ }
209
+ },
210
+ "node_modules/@babel/parser": {
211
+ "version": "7.28.5",
212
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
213
+ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
214
+ "dev": true,
215
+ "license": "MIT",
216
+ "dependencies": {
217
+ "@babel/types": "^7.28.5"
218
+ },
219
+ "bin": {
220
+ "parser": "bin/babel-parser.js"
221
+ },
222
+ "engines": {
223
+ "node": ">=6.0.0"
224
+ }
225
+ },
226
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
227
+ "version": "7.27.1",
228
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
229
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
230
+ "dev": true,
231
+ "license": "MIT",
232
+ "dependencies": {
233
+ "@babel/helper-plugin-utils": "^7.27.1"
234
+ },
235
+ "engines": {
236
+ "node": ">=6.9.0"
237
+ },
238
+ "peerDependencies": {
239
+ "@babel/core": "^7.0.0-0"
240
+ }
241
+ },
242
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
243
+ "version": "7.27.1",
244
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
245
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
246
+ "dev": true,
247
+ "license": "MIT",
248
+ "dependencies": {
249
+ "@babel/helper-plugin-utils": "^7.27.1"
250
+ },
251
+ "engines": {
252
+ "node": ">=6.9.0"
253
+ },
254
+ "peerDependencies": {
255
+ "@babel/core": "^7.0.0-0"
256
+ }
257
+ },
258
+ "node_modules/@babel/template": {
259
+ "version": "7.27.2",
260
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
261
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
262
+ "dev": true,
263
+ "license": "MIT",
264
+ "dependencies": {
265
+ "@babel/code-frame": "^7.27.1",
266
+ "@babel/parser": "^7.27.2",
267
+ "@babel/types": "^7.27.1"
268
+ },
269
+ "engines": {
270
+ "node": ">=6.9.0"
271
+ }
272
+ },
273
+ "node_modules/@babel/traverse": {
274
+ "version": "7.28.5",
275
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
276
+ "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
277
+ "dev": true,
278
+ "license": "MIT",
279
+ "dependencies": {
280
+ "@babel/code-frame": "^7.27.1",
281
+ "@babel/generator": "^7.28.5",
282
+ "@babel/helper-globals": "^7.28.0",
283
+ "@babel/parser": "^7.28.5",
284
+ "@babel/template": "^7.27.2",
285
+ "@babel/types": "^7.28.5",
286
+ "debug": "^4.3.1"
287
+ },
288
+ "engines": {
289
+ "node": ">=6.9.0"
290
+ }
291
+ },
292
+ "node_modules/@babel/types": {
293
+ "version": "7.28.5",
294
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
295
+ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
296
+ "dev": true,
297
+ "license": "MIT",
298
+ "dependencies": {
299
+ "@babel/helper-string-parser": "^7.27.1",
300
+ "@babel/helper-validator-identifier": "^7.28.5"
301
+ },
302
+ "engines": {
303
+ "node": ">=6.9.0"
304
+ }
305
+ },
306
+ "node_modules/@esbuild/aix-ppc64": {
307
+ "version": "0.25.12",
308
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
309
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
310
+ "cpu": [
311
+ "ppc64"
312
+ ],
313
+ "dev": true,
314
+ "license": "MIT",
315
+ "optional": true,
316
+ "os": [
317
+ "aix"
318
+ ],
319
+ "engines": {
320
+ "node": ">=18"
321
+ }
322
+ },
323
+ "node_modules/@esbuild/android-arm": {
324
+ "version": "0.25.12",
325
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
326
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
327
+ "cpu": [
328
+ "arm"
329
+ ],
330
+ "dev": true,
331
+ "license": "MIT",
332
+ "optional": true,
333
+ "os": [
334
+ "android"
335
+ ],
336
+ "engines": {
337
+ "node": ">=18"
338
+ }
339
+ },
340
+ "node_modules/@esbuild/android-arm64": {
341
+ "version": "0.25.12",
342
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
343
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
344
+ "cpu": [
345
+ "arm64"
346
+ ],
347
+ "dev": true,
348
+ "license": "MIT",
349
+ "optional": true,
350
+ "os": [
351
+ "android"
352
+ ],
353
+ "engines": {
354
+ "node": ">=18"
355
+ }
356
+ },
357
+ "node_modules/@esbuild/android-x64": {
358
+ "version": "0.25.12",
359
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
360
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
361
+ "cpu": [
362
+ "x64"
363
+ ],
364
+ "dev": true,
365
+ "license": "MIT",
366
+ "optional": true,
367
+ "os": [
368
+ "android"
369
+ ],
370
+ "engines": {
371
+ "node": ">=18"
372
+ }
373
+ },
374
+ "node_modules/@esbuild/darwin-arm64": {
375
+ "version": "0.25.12",
376
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
377
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
378
+ "cpu": [
379
+ "arm64"
380
+ ],
381
+ "dev": true,
382
+ "license": "MIT",
383
+ "optional": true,
384
+ "os": [
385
+ "darwin"
386
+ ],
387
+ "engines": {
388
+ "node": ">=18"
389
+ }
390
+ },
391
+ "node_modules/@esbuild/darwin-x64": {
392
+ "version": "0.25.12",
393
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
394
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
395
+ "cpu": [
396
+ "x64"
397
+ ],
398
+ "dev": true,
399
+ "license": "MIT",
400
+ "optional": true,
401
+ "os": [
402
+ "darwin"
403
+ ],
404
+ "engines": {
405
+ "node": ">=18"
406
+ }
407
+ },
408
+ "node_modules/@esbuild/freebsd-arm64": {
409
+ "version": "0.25.12",
410
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
411
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
412
+ "cpu": [
413
+ "arm64"
414
+ ],
415
+ "dev": true,
416
+ "license": "MIT",
417
+ "optional": true,
418
+ "os": [
419
+ "freebsd"
420
+ ],
421
+ "engines": {
422
+ "node": ">=18"
423
+ }
424
+ },
425
+ "node_modules/@esbuild/freebsd-x64": {
426
+ "version": "0.25.12",
427
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
428
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
429
+ "cpu": [
430
+ "x64"
431
+ ],
432
+ "dev": true,
433
+ "license": "MIT",
434
+ "optional": true,
435
+ "os": [
436
+ "freebsd"
437
+ ],
438
+ "engines": {
439
+ "node": ">=18"
440
+ }
441
+ },
442
+ "node_modules/@esbuild/linux-arm": {
443
+ "version": "0.25.12",
444
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
445
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
446
+ "cpu": [
447
+ "arm"
448
+ ],
449
+ "dev": true,
450
+ "license": "MIT",
451
+ "optional": true,
452
+ "os": [
453
+ "linux"
454
+ ],
455
+ "engines": {
456
+ "node": ">=18"
457
+ }
458
+ },
459
+ "node_modules/@esbuild/linux-arm64": {
460
+ "version": "0.25.12",
461
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
462
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
463
+ "cpu": [
464
+ "arm64"
465
+ ],
466
+ "dev": true,
467
+ "license": "MIT",
468
+ "optional": true,
469
+ "os": [
470
+ "linux"
471
+ ],
472
+ "engines": {
473
+ "node": ">=18"
474
+ }
475
+ },
476
+ "node_modules/@esbuild/linux-ia32": {
477
+ "version": "0.25.12",
478
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
479
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
480
+ "cpu": [
481
+ "ia32"
482
+ ],
483
+ "dev": true,
484
+ "license": "MIT",
485
+ "optional": true,
486
+ "os": [
487
+ "linux"
488
+ ],
489
+ "engines": {
490
+ "node": ">=18"
491
+ }
492
+ },
493
+ "node_modules/@esbuild/linux-loong64": {
494
+ "version": "0.25.12",
495
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
496
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
497
+ "cpu": [
498
+ "loong64"
499
+ ],
500
+ "dev": true,
501
+ "license": "MIT",
502
+ "optional": true,
503
+ "os": [
504
+ "linux"
505
+ ],
506
+ "engines": {
507
+ "node": ">=18"
508
+ }
509
+ },
510
+ "node_modules/@esbuild/linux-mips64el": {
511
+ "version": "0.25.12",
512
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
513
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
514
+ "cpu": [
515
+ "mips64el"
516
+ ],
517
+ "dev": true,
518
+ "license": "MIT",
519
+ "optional": true,
520
+ "os": [
521
+ "linux"
522
+ ],
523
+ "engines": {
524
+ "node": ">=18"
525
+ }
526
+ },
527
+ "node_modules/@esbuild/linux-ppc64": {
528
+ "version": "0.25.12",
529
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
530
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
531
+ "cpu": [
532
+ "ppc64"
533
+ ],
534
+ "dev": true,
535
+ "license": "MIT",
536
+ "optional": true,
537
+ "os": [
538
+ "linux"
539
+ ],
540
+ "engines": {
541
+ "node": ">=18"
542
+ }
543
+ },
544
+ "node_modules/@esbuild/linux-riscv64": {
545
+ "version": "0.25.12",
546
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
547
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
548
+ "cpu": [
549
+ "riscv64"
550
+ ],
551
+ "dev": true,
552
+ "license": "MIT",
553
+ "optional": true,
554
+ "os": [
555
+ "linux"
556
+ ],
557
+ "engines": {
558
+ "node": ">=18"
559
+ }
560
+ },
561
+ "node_modules/@esbuild/linux-s390x": {
562
+ "version": "0.25.12",
563
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
564
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
565
+ "cpu": [
566
+ "s390x"
567
+ ],
568
+ "dev": true,
569
+ "license": "MIT",
570
+ "optional": true,
571
+ "os": [
572
+ "linux"
573
+ ],
574
+ "engines": {
575
+ "node": ">=18"
576
+ }
577
+ },
578
+ "node_modules/@esbuild/linux-x64": {
579
+ "version": "0.25.12",
580
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
581
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
582
+ "cpu": [
583
+ "x64"
584
+ ],
585
+ "dev": true,
586
+ "license": "MIT",
587
+ "optional": true,
588
+ "os": [
589
+ "linux"
590
+ ],
591
+ "engines": {
592
+ "node": ">=18"
593
+ }
594
+ },
595
+ "node_modules/@esbuild/netbsd-arm64": {
596
+ "version": "0.25.12",
597
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
598
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
599
+ "cpu": [
600
+ "arm64"
601
+ ],
602
+ "dev": true,
603
+ "license": "MIT",
604
+ "optional": true,
605
+ "os": [
606
+ "netbsd"
607
+ ],
608
+ "engines": {
609
+ "node": ">=18"
610
+ }
611
+ },
612
+ "node_modules/@esbuild/netbsd-x64": {
613
+ "version": "0.25.12",
614
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
615
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
616
+ "cpu": [
617
+ "x64"
618
+ ],
619
+ "dev": true,
620
+ "license": "MIT",
621
+ "optional": true,
622
+ "os": [
623
+ "netbsd"
624
+ ],
625
+ "engines": {
626
+ "node": ">=18"
627
+ }
628
+ },
629
+ "node_modules/@esbuild/openbsd-arm64": {
630
+ "version": "0.25.12",
631
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
632
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
633
+ "cpu": [
634
+ "arm64"
635
+ ],
636
+ "dev": true,
637
+ "license": "MIT",
638
+ "optional": true,
639
+ "os": [
640
+ "openbsd"
641
+ ],
642
+ "engines": {
643
+ "node": ">=18"
644
+ }
645
+ },
646
+ "node_modules/@esbuild/openbsd-x64": {
647
+ "version": "0.25.12",
648
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
649
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
650
+ "cpu": [
651
+ "x64"
652
+ ],
653
+ "dev": true,
654
+ "license": "MIT",
655
+ "optional": true,
656
+ "os": [
657
+ "openbsd"
658
+ ],
659
+ "engines": {
660
+ "node": ">=18"
661
+ }
662
+ },
663
+ "node_modules/@esbuild/openharmony-arm64": {
664
+ "version": "0.25.12",
665
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
666
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
667
+ "cpu": [
668
+ "arm64"
669
+ ],
670
+ "dev": true,
671
+ "license": "MIT",
672
+ "optional": true,
673
+ "os": [
674
+ "openharmony"
675
+ ],
676
+ "engines": {
677
+ "node": ">=18"
678
+ }
679
+ },
680
+ "node_modules/@esbuild/sunos-x64": {
681
+ "version": "0.25.12",
682
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
683
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
684
+ "cpu": [
685
+ "x64"
686
+ ],
687
+ "dev": true,
688
+ "license": "MIT",
689
+ "optional": true,
690
+ "os": [
691
+ "sunos"
692
+ ],
693
+ "engines": {
694
+ "node": ">=18"
695
+ }
696
+ },
697
+ "node_modules/@esbuild/win32-arm64": {
698
+ "version": "0.25.12",
699
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
700
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
701
+ "cpu": [
702
+ "arm64"
703
+ ],
704
+ "dev": true,
705
+ "license": "MIT",
706
+ "optional": true,
707
+ "os": [
708
+ "win32"
709
+ ],
710
+ "engines": {
711
+ "node": ">=18"
712
+ }
713
+ },
714
+ "node_modules/@esbuild/win32-ia32": {
715
+ "version": "0.25.12",
716
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
717
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
718
+ "cpu": [
719
+ "ia32"
720
+ ],
721
+ "dev": true,
722
+ "license": "MIT",
723
+ "optional": true,
724
+ "os": [
725
+ "win32"
726
+ ],
727
+ "engines": {
728
+ "node": ">=18"
729
+ }
730
+ },
731
+ "node_modules/@esbuild/win32-x64": {
732
+ "version": "0.25.12",
733
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
734
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
735
+ "cpu": [
736
+ "x64"
737
+ ],
738
+ "dev": true,
739
+ "license": "MIT",
740
+ "optional": true,
741
+ "os": [
742
+ "win32"
743
+ ],
744
+ "engines": {
745
+ "node": ">=18"
746
+ }
747
+ },
748
+ "node_modules/@jridgewell/gen-mapping": {
749
+ "version": "0.3.13",
750
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
751
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
752
+ "dev": true,
753
+ "license": "MIT",
754
+ "dependencies": {
755
+ "@jridgewell/sourcemap-codec": "^1.5.0",
756
+ "@jridgewell/trace-mapping": "^0.3.24"
757
+ }
758
+ },
759
+ "node_modules/@jridgewell/remapping": {
760
+ "version": "2.3.5",
761
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
762
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
763
+ "dev": true,
764
+ "license": "MIT",
765
+ "dependencies": {
766
+ "@jridgewell/gen-mapping": "^0.3.5",
767
+ "@jridgewell/trace-mapping": "^0.3.24"
768
+ }
769
+ },
770
+ "node_modules/@jridgewell/resolve-uri": {
771
+ "version": "3.1.2",
772
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
773
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
774
+ "dev": true,
775
+ "license": "MIT",
776
+ "engines": {
777
+ "node": ">=6.0.0"
778
+ }
779
+ },
780
+ "node_modules/@jridgewell/sourcemap-codec": {
781
+ "version": "1.5.5",
782
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
783
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
784
+ "dev": true,
785
+ "license": "MIT"
786
+ },
787
+ "node_modules/@jridgewell/trace-mapping": {
788
+ "version": "0.3.31",
789
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
790
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
791
+ "dev": true,
792
+ "license": "MIT",
793
+ "dependencies": {
794
+ "@jridgewell/resolve-uri": "^3.1.0",
795
+ "@jridgewell/sourcemap-codec": "^1.4.14"
796
+ }
797
+ },
798
+ "node_modules/@reduxjs/toolkit": {
799
+ "version": "2.11.2",
800
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
801
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
802
+ "license": "MIT",
803
+ "dependencies": {
804
+ "@standard-schema/spec": "^1.0.0",
805
+ "@standard-schema/utils": "^0.3.0",
806
+ "immer": "^11.0.0",
807
+ "redux": "^5.0.1",
808
+ "redux-thunk": "^3.1.0",
809
+ "reselect": "^5.1.0"
810
+ },
811
+ "peerDependencies": {
812
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
813
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
814
+ },
815
+ "peerDependenciesMeta": {
816
+ "react": {
817
+ "optional": true
818
+ },
819
+ "react-redux": {
820
+ "optional": true
821
+ }
822
+ }
823
+ },
824
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
825
+ "version": "11.1.3",
826
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
827
+ "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
828
+ "license": "MIT",
829
+ "funding": {
830
+ "type": "opencollective",
831
+ "url": "https://opencollective.com/immer"
832
+ }
833
+ },
834
+ "node_modules/@rolldown/pluginutils": {
835
+ "version": "1.0.0-beta.53",
836
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
837
+ "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
838
+ "dev": true,
839
+ "license": "MIT"
840
+ },
841
+ "node_modules/@rollup/rollup-android-arm-eabi": {
842
+ "version": "4.54.0",
843
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
844
+ "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
845
+ "cpu": [
846
+ "arm"
847
+ ],
848
+ "dev": true,
849
+ "license": "MIT",
850
+ "optional": true,
851
+ "os": [
852
+ "android"
853
+ ]
854
+ },
855
+ "node_modules/@rollup/rollup-android-arm64": {
856
+ "version": "4.54.0",
857
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
858
+ "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
859
+ "cpu": [
860
+ "arm64"
861
+ ],
862
+ "dev": true,
863
+ "license": "MIT",
864
+ "optional": true,
865
+ "os": [
866
+ "android"
867
+ ]
868
+ },
869
+ "node_modules/@rollup/rollup-darwin-arm64": {
870
+ "version": "4.54.0",
871
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
872
+ "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
873
+ "cpu": [
874
+ "arm64"
875
+ ],
876
+ "dev": true,
877
+ "license": "MIT",
878
+ "optional": true,
879
+ "os": [
880
+ "darwin"
881
+ ]
882
+ },
883
+ "node_modules/@rollup/rollup-darwin-x64": {
884
+ "version": "4.54.0",
885
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
886
+ "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
887
+ "cpu": [
888
+ "x64"
889
+ ],
890
+ "dev": true,
891
+ "license": "MIT",
892
+ "optional": true,
893
+ "os": [
894
+ "darwin"
895
+ ]
896
+ },
897
+ "node_modules/@rollup/rollup-freebsd-arm64": {
898
+ "version": "4.54.0",
899
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
900
+ "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
901
+ "cpu": [
902
+ "arm64"
903
+ ],
904
+ "dev": true,
905
+ "license": "MIT",
906
+ "optional": true,
907
+ "os": [
908
+ "freebsd"
909
+ ]
910
+ },
911
+ "node_modules/@rollup/rollup-freebsd-x64": {
912
+ "version": "4.54.0",
913
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
914
+ "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
915
+ "cpu": [
916
+ "x64"
917
+ ],
918
+ "dev": true,
919
+ "license": "MIT",
920
+ "optional": true,
921
+ "os": [
922
+ "freebsd"
923
+ ]
924
+ },
925
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
926
+ "version": "4.54.0",
927
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
928
+ "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
929
+ "cpu": [
930
+ "arm"
931
+ ],
932
+ "dev": true,
933
+ "license": "MIT",
934
+ "optional": true,
935
+ "os": [
936
+ "linux"
937
+ ]
938
+ },
939
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
940
+ "version": "4.54.0",
941
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
942
+ "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
943
+ "cpu": [
944
+ "arm"
945
+ ],
946
+ "dev": true,
947
+ "license": "MIT",
948
+ "optional": true,
949
+ "os": [
950
+ "linux"
951
+ ]
952
+ },
953
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
954
+ "version": "4.54.0",
955
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
956
+ "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
957
+ "cpu": [
958
+ "arm64"
959
+ ],
960
+ "dev": true,
961
+ "license": "MIT",
962
+ "optional": true,
963
+ "os": [
964
+ "linux"
965
+ ]
966
+ },
967
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
968
+ "version": "4.54.0",
969
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
970
+ "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
971
+ "cpu": [
972
+ "arm64"
973
+ ],
974
+ "dev": true,
975
+ "license": "MIT",
976
+ "optional": true,
977
+ "os": [
978
+ "linux"
979
+ ]
980
+ },
981
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
982
+ "version": "4.54.0",
983
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
984
+ "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
985
+ "cpu": [
986
+ "loong64"
987
+ ],
988
+ "dev": true,
989
+ "license": "MIT",
990
+ "optional": true,
991
+ "os": [
992
+ "linux"
993
+ ]
994
+ },
995
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
996
+ "version": "4.54.0",
997
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
998
+ "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
999
+ "cpu": [
1000
+ "ppc64"
1001
+ ],
1002
+ "dev": true,
1003
+ "license": "MIT",
1004
+ "optional": true,
1005
+ "os": [
1006
+ "linux"
1007
+ ]
1008
+ },
1009
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1010
+ "version": "4.54.0",
1011
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
1012
+ "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
1013
+ "cpu": [
1014
+ "riscv64"
1015
+ ],
1016
+ "dev": true,
1017
+ "license": "MIT",
1018
+ "optional": true,
1019
+ "os": [
1020
+ "linux"
1021
+ ]
1022
+ },
1023
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
1024
+ "version": "4.54.0",
1025
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
1026
+ "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
1027
+ "cpu": [
1028
+ "riscv64"
1029
+ ],
1030
+ "dev": true,
1031
+ "license": "MIT",
1032
+ "optional": true,
1033
+ "os": [
1034
+ "linux"
1035
+ ]
1036
+ },
1037
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1038
+ "version": "4.54.0",
1039
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
1040
+ "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
1041
+ "cpu": [
1042
+ "s390x"
1043
+ ],
1044
+ "dev": true,
1045
+ "license": "MIT",
1046
+ "optional": true,
1047
+ "os": [
1048
+ "linux"
1049
+ ]
1050
+ },
1051
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1052
+ "version": "4.54.0",
1053
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
1054
+ "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
1055
+ "cpu": [
1056
+ "x64"
1057
+ ],
1058
+ "dev": true,
1059
+ "license": "MIT",
1060
+ "optional": true,
1061
+ "os": [
1062
+ "linux"
1063
+ ]
1064
+ },
1065
+ "node_modules/@rollup/rollup-linux-x64-musl": {
1066
+ "version": "4.54.0",
1067
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
1068
+ "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
1069
+ "cpu": [
1070
+ "x64"
1071
+ ],
1072
+ "dev": true,
1073
+ "license": "MIT",
1074
+ "optional": true,
1075
+ "os": [
1076
+ "linux"
1077
+ ]
1078
+ },
1079
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1080
+ "version": "4.54.0",
1081
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
1082
+ "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
1083
+ "cpu": [
1084
+ "arm64"
1085
+ ],
1086
+ "dev": true,
1087
+ "license": "MIT",
1088
+ "optional": true,
1089
+ "os": [
1090
+ "openharmony"
1091
+ ]
1092
+ },
1093
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1094
+ "version": "4.54.0",
1095
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
1096
+ "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
1097
+ "cpu": [
1098
+ "arm64"
1099
+ ],
1100
+ "dev": true,
1101
+ "license": "MIT",
1102
+ "optional": true,
1103
+ "os": [
1104
+ "win32"
1105
+ ]
1106
+ },
1107
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1108
+ "version": "4.54.0",
1109
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
1110
+ "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
1111
+ "cpu": [
1112
+ "ia32"
1113
+ ],
1114
+ "dev": true,
1115
+ "license": "MIT",
1116
+ "optional": true,
1117
+ "os": [
1118
+ "win32"
1119
+ ]
1120
+ },
1121
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1122
+ "version": "4.54.0",
1123
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
1124
+ "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
1125
+ "cpu": [
1126
+ "x64"
1127
+ ],
1128
+ "dev": true,
1129
+ "license": "MIT",
1130
+ "optional": true,
1131
+ "os": [
1132
+ "win32"
1133
+ ]
1134
+ },
1135
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1136
+ "version": "4.54.0",
1137
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
1138
+ "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
1139
+ "cpu": [
1140
+ "x64"
1141
+ ],
1142
+ "dev": true,
1143
+ "license": "MIT",
1144
+ "optional": true,
1145
+ "os": [
1146
+ "win32"
1147
+ ]
1148
+ },
1149
+ "node_modules/@standard-schema/spec": {
1150
+ "version": "1.1.0",
1151
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1152
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1153
+ "license": "MIT"
1154
+ },
1155
+ "node_modules/@standard-schema/utils": {
1156
+ "version": "0.3.0",
1157
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
1158
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
1159
+ "license": "MIT"
1160
+ },
1161
+ "node_modules/@types/babel__core": {
1162
+ "version": "7.20.5",
1163
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1164
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1165
+ "dev": true,
1166
+ "license": "MIT",
1167
+ "dependencies": {
1168
+ "@babel/parser": "^7.20.7",
1169
+ "@babel/types": "^7.20.7",
1170
+ "@types/babel__generator": "*",
1171
+ "@types/babel__template": "*",
1172
+ "@types/babel__traverse": "*"
1173
+ }
1174
+ },
1175
+ "node_modules/@types/babel__generator": {
1176
+ "version": "7.27.0",
1177
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1178
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1179
+ "dev": true,
1180
+ "license": "MIT",
1181
+ "dependencies": {
1182
+ "@babel/types": "^7.0.0"
1183
+ }
1184
+ },
1185
+ "node_modules/@types/babel__template": {
1186
+ "version": "7.4.4",
1187
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1188
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1189
+ "dev": true,
1190
+ "license": "MIT",
1191
+ "dependencies": {
1192
+ "@babel/parser": "^7.1.0",
1193
+ "@babel/types": "^7.0.0"
1194
+ }
1195
+ },
1196
+ "node_modules/@types/babel__traverse": {
1197
+ "version": "7.28.0",
1198
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1199
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1200
+ "dev": true,
1201
+ "license": "MIT",
1202
+ "dependencies": {
1203
+ "@babel/types": "^7.28.2"
1204
+ }
1205
+ },
1206
+ "node_modules/@types/d3-array": {
1207
+ "version": "3.2.2",
1208
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
1209
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
1210
+ "license": "MIT"
1211
+ },
1212
+ "node_modules/@types/d3-color": {
1213
+ "version": "3.1.3",
1214
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
1215
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
1216
+ "license": "MIT"
1217
+ },
1218
+ "node_modules/@types/d3-ease": {
1219
+ "version": "3.0.2",
1220
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
1221
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
1222
+ "license": "MIT"
1223
+ },
1224
+ "node_modules/@types/d3-interpolate": {
1225
+ "version": "3.0.4",
1226
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
1227
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
1228
+ "license": "MIT",
1229
+ "dependencies": {
1230
+ "@types/d3-color": "*"
1231
+ }
1232
+ },
1233
+ "node_modules/@types/d3-path": {
1234
+ "version": "3.1.1",
1235
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
1236
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
1237
+ "license": "MIT"
1238
+ },
1239
+ "node_modules/@types/d3-scale": {
1240
+ "version": "4.0.9",
1241
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
1242
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
1243
+ "license": "MIT",
1244
+ "dependencies": {
1245
+ "@types/d3-time": "*"
1246
+ }
1247
+ },
1248
+ "node_modules/@types/d3-shape": {
1249
+ "version": "3.1.7",
1250
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
1251
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
1252
+ "license": "MIT",
1253
+ "dependencies": {
1254
+ "@types/d3-path": "*"
1255
+ }
1256
+ },
1257
+ "node_modules/@types/d3-time": {
1258
+ "version": "3.0.4",
1259
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
1260
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
1261
+ "license": "MIT"
1262
+ },
1263
+ "node_modules/@types/d3-timer": {
1264
+ "version": "3.0.2",
1265
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
1266
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
1267
+ "license": "MIT"
1268
+ },
1269
+ "node_modules/@types/estree": {
1270
+ "version": "1.0.8",
1271
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1272
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1273
+ "dev": true,
1274
+ "license": "MIT"
1275
+ },
1276
+ "node_modules/@types/node": {
1277
+ "version": "22.19.3",
1278
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
1279
+ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
1280
+ "dev": true,
1281
+ "license": "MIT",
1282
+ "peer": true,
1283
+ "dependencies": {
1284
+ "undici-types": "~6.21.0"
1285
+ }
1286
+ },
1287
+ "node_modules/@types/use-sync-external-store": {
1288
+ "version": "0.0.6",
1289
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
1290
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
1291
+ "license": "MIT"
1292
+ },
1293
+ "node_modules/@vitejs/plugin-react": {
1294
+ "version": "5.1.2",
1295
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
1296
+ "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==",
1297
+ "dev": true,
1298
+ "license": "MIT",
1299
+ "dependencies": {
1300
+ "@babel/core": "^7.28.5",
1301
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1302
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1303
+ "@rolldown/pluginutils": "1.0.0-beta.53",
1304
+ "@types/babel__core": "^7.20.5",
1305
+ "react-refresh": "^0.18.0"
1306
+ },
1307
+ "engines": {
1308
+ "node": "^20.19.0 || >=22.12.0"
1309
+ },
1310
+ "peerDependencies": {
1311
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1312
+ }
1313
+ },
1314
+ "node_modules/baseline-browser-mapping": {
1315
+ "version": "2.9.11",
1316
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
1317
+ "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
1318
+ "dev": true,
1319
+ "license": "Apache-2.0",
1320
+ "bin": {
1321
+ "baseline-browser-mapping": "dist/cli.js"
1322
+ }
1323
+ },
1324
+ "node_modules/browserslist": {
1325
+ "version": "4.28.1",
1326
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
1327
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
1328
+ "dev": true,
1329
+ "funding": [
1330
+ {
1331
+ "type": "opencollective",
1332
+ "url": "https://opencollective.com/browserslist"
1333
+ },
1334
+ {
1335
+ "type": "tidelift",
1336
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1337
+ },
1338
+ {
1339
+ "type": "github",
1340
+ "url": "https://github.com/sponsors/ai"
1341
+ }
1342
+ ],
1343
+ "license": "MIT",
1344
+ "peer": true,
1345
+ "dependencies": {
1346
+ "baseline-browser-mapping": "^2.9.0",
1347
+ "caniuse-lite": "^1.0.30001759",
1348
+ "electron-to-chromium": "^1.5.263",
1349
+ "node-releases": "^2.0.27",
1350
+ "update-browserslist-db": "^1.2.0"
1351
+ },
1352
+ "bin": {
1353
+ "browserslist": "cli.js"
1354
+ },
1355
+ "engines": {
1356
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1357
+ }
1358
+ },
1359
+ "node_modules/caniuse-lite": {
1360
+ "version": "1.0.30001762",
1361
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
1362
+ "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
1363
+ "dev": true,
1364
+ "funding": [
1365
+ {
1366
+ "type": "opencollective",
1367
+ "url": "https://opencollective.com/browserslist"
1368
+ },
1369
+ {
1370
+ "type": "tidelift",
1371
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1372
+ },
1373
+ {
1374
+ "type": "github",
1375
+ "url": "https://github.com/sponsors/ai"
1376
+ }
1377
+ ],
1378
+ "license": "CC-BY-4.0"
1379
+ },
1380
+ "node_modules/clsx": {
1381
+ "version": "2.1.1",
1382
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
1383
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
1384
+ "license": "MIT",
1385
+ "engines": {
1386
+ "node": ">=6"
1387
+ }
1388
+ },
1389
+ "node_modules/convert-source-map": {
1390
+ "version": "2.0.0",
1391
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1392
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1393
+ "dev": true,
1394
+ "license": "MIT"
1395
+ },
1396
+ "node_modules/cookie": {
1397
+ "version": "1.1.1",
1398
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
1399
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
1400
+ "license": "MIT",
1401
+ "engines": {
1402
+ "node": ">=18"
1403
+ },
1404
+ "funding": {
1405
+ "type": "opencollective",
1406
+ "url": "https://opencollective.com/express"
1407
+ }
1408
+ },
1409
+ "node_modules/d3-array": {
1410
+ "version": "3.2.4",
1411
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
1412
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
1413
+ "license": "ISC",
1414
+ "dependencies": {
1415
+ "internmap": "1 - 2"
1416
+ },
1417
+ "engines": {
1418
+ "node": ">=12"
1419
+ }
1420
+ },
1421
+ "node_modules/d3-color": {
1422
+ "version": "3.1.0",
1423
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
1424
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
1425
+ "license": "ISC",
1426
+ "engines": {
1427
+ "node": ">=12"
1428
+ }
1429
+ },
1430
+ "node_modules/d3-ease": {
1431
+ "version": "3.0.1",
1432
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
1433
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
1434
+ "license": "BSD-3-Clause",
1435
+ "engines": {
1436
+ "node": ">=12"
1437
+ }
1438
+ },
1439
+ "node_modules/d3-format": {
1440
+ "version": "3.1.0",
1441
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
1442
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
1443
+ "license": "ISC",
1444
+ "engines": {
1445
+ "node": ">=12"
1446
+ }
1447
+ },
1448
+ "node_modules/d3-interpolate": {
1449
+ "version": "3.0.1",
1450
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
1451
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
1452
+ "license": "ISC",
1453
+ "dependencies": {
1454
+ "d3-color": "1 - 3"
1455
+ },
1456
+ "engines": {
1457
+ "node": ">=12"
1458
+ }
1459
+ },
1460
+ "node_modules/d3-path": {
1461
+ "version": "3.1.0",
1462
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
1463
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
1464
+ "license": "ISC",
1465
+ "engines": {
1466
+ "node": ">=12"
1467
+ }
1468
+ },
1469
+ "node_modules/d3-scale": {
1470
+ "version": "4.0.2",
1471
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
1472
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
1473
+ "license": "ISC",
1474
+ "dependencies": {
1475
+ "d3-array": "2.10.0 - 3",
1476
+ "d3-format": "1 - 3",
1477
+ "d3-interpolate": "1.2.0 - 3",
1478
+ "d3-time": "2.1.1 - 3",
1479
+ "d3-time-format": "2 - 4"
1480
+ },
1481
+ "engines": {
1482
+ "node": ">=12"
1483
+ }
1484
+ },
1485
+ "node_modules/d3-shape": {
1486
+ "version": "3.2.0",
1487
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
1488
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
1489
+ "license": "ISC",
1490
+ "dependencies": {
1491
+ "d3-path": "^3.1.0"
1492
+ },
1493
+ "engines": {
1494
+ "node": ">=12"
1495
+ }
1496
+ },
1497
+ "node_modules/d3-time": {
1498
+ "version": "3.1.0",
1499
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
1500
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
1501
+ "license": "ISC",
1502
+ "dependencies": {
1503
+ "d3-array": "2 - 3"
1504
+ },
1505
+ "engines": {
1506
+ "node": ">=12"
1507
+ }
1508
+ },
1509
+ "node_modules/d3-time-format": {
1510
+ "version": "4.1.0",
1511
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
1512
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
1513
+ "license": "ISC",
1514
+ "dependencies": {
1515
+ "d3-time": "1 - 3"
1516
+ },
1517
+ "engines": {
1518
+ "node": ">=12"
1519
+ }
1520
+ },
1521
+ "node_modules/d3-timer": {
1522
+ "version": "3.0.1",
1523
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
1524
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
1525
+ "license": "ISC",
1526
+ "engines": {
1527
+ "node": ">=12"
1528
+ }
1529
+ },
1530
+ "node_modules/debug": {
1531
+ "version": "4.4.3",
1532
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1533
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1534
+ "dev": true,
1535
+ "license": "MIT",
1536
+ "dependencies": {
1537
+ "ms": "^2.1.3"
1538
+ },
1539
+ "engines": {
1540
+ "node": ">=6.0"
1541
+ },
1542
+ "peerDependenciesMeta": {
1543
+ "supports-color": {
1544
+ "optional": true
1545
+ }
1546
+ }
1547
+ },
1548
+ "node_modules/decimal.js-light": {
1549
+ "version": "2.5.1",
1550
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
1551
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
1552
+ "license": "MIT"
1553
+ },
1554
+ "node_modules/electron-to-chromium": {
1555
+ "version": "1.5.267",
1556
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
1557
+ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
1558
+ "dev": true,
1559
+ "license": "ISC"
1560
+ },
1561
+ "node_modules/es-toolkit": {
1562
+ "version": "1.43.0",
1563
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
1564
+ "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
1565
+ "license": "MIT",
1566
+ "workspaces": [
1567
+ "docs",
1568
+ "benchmarks"
1569
+ ]
1570
+ },
1571
+ "node_modules/esbuild": {
1572
+ "version": "0.25.12",
1573
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
1574
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
1575
+ "dev": true,
1576
+ "hasInstallScript": true,
1577
+ "license": "MIT",
1578
+ "bin": {
1579
+ "esbuild": "bin/esbuild"
1580
+ },
1581
+ "engines": {
1582
+ "node": ">=18"
1583
+ },
1584
+ "optionalDependencies": {
1585
+ "@esbuild/aix-ppc64": "0.25.12",
1586
+ "@esbuild/android-arm": "0.25.12",
1587
+ "@esbuild/android-arm64": "0.25.12",
1588
+ "@esbuild/android-x64": "0.25.12",
1589
+ "@esbuild/darwin-arm64": "0.25.12",
1590
+ "@esbuild/darwin-x64": "0.25.12",
1591
+ "@esbuild/freebsd-arm64": "0.25.12",
1592
+ "@esbuild/freebsd-x64": "0.25.12",
1593
+ "@esbuild/linux-arm": "0.25.12",
1594
+ "@esbuild/linux-arm64": "0.25.12",
1595
+ "@esbuild/linux-ia32": "0.25.12",
1596
+ "@esbuild/linux-loong64": "0.25.12",
1597
+ "@esbuild/linux-mips64el": "0.25.12",
1598
+ "@esbuild/linux-ppc64": "0.25.12",
1599
+ "@esbuild/linux-riscv64": "0.25.12",
1600
+ "@esbuild/linux-s390x": "0.25.12",
1601
+ "@esbuild/linux-x64": "0.25.12",
1602
+ "@esbuild/netbsd-arm64": "0.25.12",
1603
+ "@esbuild/netbsd-x64": "0.25.12",
1604
+ "@esbuild/openbsd-arm64": "0.25.12",
1605
+ "@esbuild/openbsd-x64": "0.25.12",
1606
+ "@esbuild/openharmony-arm64": "0.25.12",
1607
+ "@esbuild/sunos-x64": "0.25.12",
1608
+ "@esbuild/win32-arm64": "0.25.12",
1609
+ "@esbuild/win32-ia32": "0.25.12",
1610
+ "@esbuild/win32-x64": "0.25.12"
1611
+ }
1612
+ },
1613
+ "node_modules/escalade": {
1614
+ "version": "3.2.0",
1615
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1616
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1617
+ "dev": true,
1618
+ "license": "MIT",
1619
+ "engines": {
1620
+ "node": ">=6"
1621
+ }
1622
+ },
1623
+ "node_modules/eventemitter3": {
1624
+ "version": "5.0.1",
1625
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
1626
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
1627
+ "license": "MIT"
1628
+ },
1629
+ "node_modules/fdir": {
1630
+ "version": "6.5.0",
1631
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1632
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1633
+ "dev": true,
1634
+ "license": "MIT",
1635
+ "engines": {
1636
+ "node": ">=12.0.0"
1637
+ },
1638
+ "peerDependencies": {
1639
+ "picomatch": "^3 || ^4"
1640
+ },
1641
+ "peerDependenciesMeta": {
1642
+ "picomatch": {
1643
+ "optional": true
1644
+ }
1645
+ }
1646
+ },
1647
+ "node_modules/fsevents": {
1648
+ "version": "2.3.3",
1649
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1650
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1651
+ "dev": true,
1652
+ "hasInstallScript": true,
1653
+ "license": "MIT",
1654
+ "optional": true,
1655
+ "os": [
1656
+ "darwin"
1657
+ ],
1658
+ "engines": {
1659
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1660
+ }
1661
+ },
1662
+ "node_modules/gensync": {
1663
+ "version": "1.0.0-beta.2",
1664
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1665
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1666
+ "dev": true,
1667
+ "license": "MIT",
1668
+ "engines": {
1669
+ "node": ">=6.9.0"
1670
+ }
1671
+ },
1672
+ "node_modules/immer": {
1673
+ "version": "10.2.0",
1674
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
1675
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
1676
+ "license": "MIT",
1677
+ "funding": {
1678
+ "type": "opencollective",
1679
+ "url": "https://opencollective.com/immer"
1680
+ }
1681
+ },
1682
+ "node_modules/internmap": {
1683
+ "version": "2.0.3",
1684
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
1685
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
1686
+ "license": "ISC",
1687
+ "engines": {
1688
+ "node": ">=12"
1689
+ }
1690
+ },
1691
+ "node_modules/js-tokens": {
1692
+ "version": "4.0.0",
1693
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1694
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1695
+ "dev": true,
1696
+ "license": "MIT"
1697
+ },
1698
+ "node_modules/jsesc": {
1699
+ "version": "3.1.0",
1700
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1701
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1702
+ "dev": true,
1703
+ "license": "MIT",
1704
+ "bin": {
1705
+ "jsesc": "bin/jsesc"
1706
+ },
1707
+ "engines": {
1708
+ "node": ">=6"
1709
+ }
1710
+ },
1711
+ "node_modules/json5": {
1712
+ "version": "2.2.3",
1713
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1714
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1715
+ "dev": true,
1716
+ "license": "MIT",
1717
+ "bin": {
1718
+ "json5": "lib/cli.js"
1719
+ },
1720
+ "engines": {
1721
+ "node": ">=6"
1722
+ }
1723
+ },
1724
+ "node_modules/lru-cache": {
1725
+ "version": "5.1.1",
1726
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1727
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1728
+ "dev": true,
1729
+ "license": "ISC",
1730
+ "dependencies": {
1731
+ "yallist": "^3.0.2"
1732
+ }
1733
+ },
1734
+ "node_modules/ms": {
1735
+ "version": "2.1.3",
1736
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1737
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1738
+ "dev": true,
1739
+ "license": "MIT"
1740
+ },
1741
+ "node_modules/nanoid": {
1742
+ "version": "3.3.11",
1743
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1744
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1745
+ "dev": true,
1746
+ "funding": [
1747
+ {
1748
+ "type": "github",
1749
+ "url": "https://github.com/sponsors/ai"
1750
+ }
1751
+ ],
1752
+ "license": "MIT",
1753
+ "bin": {
1754
+ "nanoid": "bin/nanoid.cjs"
1755
+ },
1756
+ "engines": {
1757
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1758
+ }
1759
+ },
1760
+ "node_modules/node-releases": {
1761
+ "version": "2.0.27",
1762
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
1763
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
1764
+ "dev": true,
1765
+ "license": "MIT"
1766
+ },
1767
+ "node_modules/picocolors": {
1768
+ "version": "1.1.1",
1769
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1770
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1771
+ "dev": true,
1772
+ "license": "ISC"
1773
+ },
1774
+ "node_modules/picomatch": {
1775
+ "version": "4.0.3",
1776
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
1777
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
1778
+ "dev": true,
1779
+ "license": "MIT",
1780
+ "peer": true,
1781
+ "engines": {
1782
+ "node": ">=12"
1783
+ },
1784
+ "funding": {
1785
+ "url": "https://github.com/sponsors/jonschlinkert"
1786
+ }
1787
+ },
1788
+ "node_modules/postcss": {
1789
+ "version": "8.5.6",
1790
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
1791
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
1792
+ "dev": true,
1793
+ "funding": [
1794
+ {
1795
+ "type": "opencollective",
1796
+ "url": "https://opencollective.com/postcss/"
1797
+ },
1798
+ {
1799
+ "type": "tidelift",
1800
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1801
+ },
1802
+ {
1803
+ "type": "github",
1804
+ "url": "https://github.com/sponsors/ai"
1805
+ }
1806
+ ],
1807
+ "license": "MIT",
1808
+ "dependencies": {
1809
+ "nanoid": "^3.3.11",
1810
+ "picocolors": "^1.1.1",
1811
+ "source-map-js": "^1.2.1"
1812
+ },
1813
+ "engines": {
1814
+ "node": "^10 || ^12 || >=14"
1815
+ }
1816
+ },
1817
+ "node_modules/react": {
1818
+ "version": "19.2.3",
1819
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
1820
+ "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
1821
+ "license": "MIT",
1822
+ "peer": true,
1823
+ "engines": {
1824
+ "node": ">=0.10.0"
1825
+ }
1826
+ },
1827
+ "node_modules/react-dom": {
1828
+ "version": "19.2.3",
1829
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
1830
+ "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
1831
+ "license": "MIT",
1832
+ "peer": true,
1833
+ "dependencies": {
1834
+ "scheduler": "^0.27.0"
1835
+ },
1836
+ "peerDependencies": {
1837
+ "react": "^19.2.3"
1838
+ }
1839
+ },
1840
+ "node_modules/react-is": {
1841
+ "version": "19.2.3",
1842
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
1843
+ "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
1844
+ "license": "MIT",
1845
+ "peer": true
1846
+ },
1847
+ "node_modules/react-redux": {
1848
+ "version": "9.2.0",
1849
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
1850
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
1851
+ "license": "MIT",
1852
+ "peer": true,
1853
+ "dependencies": {
1854
+ "@types/use-sync-external-store": "^0.0.6",
1855
+ "use-sync-external-store": "^1.4.0"
1856
+ },
1857
+ "peerDependencies": {
1858
+ "@types/react": "^18.2.25 || ^19",
1859
+ "react": "^18.0 || ^19",
1860
+ "redux": "^5.0.0"
1861
+ },
1862
+ "peerDependenciesMeta": {
1863
+ "@types/react": {
1864
+ "optional": true
1865
+ },
1866
+ "redux": {
1867
+ "optional": true
1868
+ }
1869
+ }
1870
+ },
1871
+ "node_modules/react-refresh": {
1872
+ "version": "0.18.0",
1873
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
1874
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
1875
+ "dev": true,
1876
+ "license": "MIT",
1877
+ "engines": {
1878
+ "node": ">=0.10.0"
1879
+ }
1880
+ },
1881
+ "node_modules/react-router": {
1882
+ "version": "7.11.0",
1883
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
1884
+ "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
1885
+ "license": "MIT",
1886
+ "dependencies": {
1887
+ "cookie": "^1.0.1",
1888
+ "set-cookie-parser": "^2.6.0"
1889
+ },
1890
+ "engines": {
1891
+ "node": ">=20.0.0"
1892
+ },
1893
+ "peerDependencies": {
1894
+ "react": ">=18",
1895
+ "react-dom": ">=18"
1896
+ },
1897
+ "peerDependenciesMeta": {
1898
+ "react-dom": {
1899
+ "optional": true
1900
+ }
1901
+ }
1902
+ },
1903
+ "node_modules/react-router-dom": {
1904
+ "version": "7.11.0",
1905
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
1906
+ "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
1907
+ "license": "MIT",
1908
+ "dependencies": {
1909
+ "react-router": "7.11.0"
1910
+ },
1911
+ "engines": {
1912
+ "node": ">=20.0.0"
1913
+ },
1914
+ "peerDependencies": {
1915
+ "react": ">=18",
1916
+ "react-dom": ">=18"
1917
+ }
1918
+ },
1919
+ "node_modules/recharts": {
1920
+ "version": "3.6.0",
1921
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
1922
+ "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
1923
+ "license": "MIT",
1924
+ "workspaces": [
1925
+ "www"
1926
+ ],
1927
+ "dependencies": {
1928
+ "@reduxjs/toolkit": "1.x.x || 2.x.x",
1929
+ "clsx": "^2.1.1",
1930
+ "decimal.js-light": "^2.5.1",
1931
+ "es-toolkit": "^1.39.3",
1932
+ "eventemitter3": "^5.0.1",
1933
+ "immer": "^10.1.1",
1934
+ "react-redux": "8.x.x || 9.x.x",
1935
+ "reselect": "5.1.1",
1936
+ "tiny-invariant": "^1.3.3",
1937
+ "use-sync-external-store": "^1.2.2",
1938
+ "victory-vendor": "^37.0.2"
1939
+ },
1940
+ "engines": {
1941
+ "node": ">=18"
1942
+ },
1943
+ "peerDependencies": {
1944
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
1945
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
1946
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1947
+ }
1948
+ },
1949
+ "node_modules/redux": {
1950
+ "version": "5.0.1",
1951
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
1952
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
1953
+ "license": "MIT",
1954
+ "peer": true
1955
+ },
1956
+ "node_modules/redux-thunk": {
1957
+ "version": "3.1.0",
1958
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
1959
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
1960
+ "license": "MIT",
1961
+ "peerDependencies": {
1962
+ "redux": "^5.0.0"
1963
+ }
1964
+ },
1965
+ "node_modules/reselect": {
1966
+ "version": "5.1.1",
1967
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
1968
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
1969
+ "license": "MIT"
1970
+ },
1971
+ "node_modules/rollup": {
1972
+ "version": "4.54.0",
1973
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
1974
+ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
1975
+ "dev": true,
1976
+ "license": "MIT",
1977
+ "dependencies": {
1978
+ "@types/estree": "1.0.8"
1979
+ },
1980
+ "bin": {
1981
+ "rollup": "dist/bin/rollup"
1982
+ },
1983
+ "engines": {
1984
+ "node": ">=18.0.0",
1985
+ "npm": ">=8.0.0"
1986
+ },
1987
+ "optionalDependencies": {
1988
+ "@rollup/rollup-android-arm-eabi": "4.54.0",
1989
+ "@rollup/rollup-android-arm64": "4.54.0",
1990
+ "@rollup/rollup-darwin-arm64": "4.54.0",
1991
+ "@rollup/rollup-darwin-x64": "4.54.0",
1992
+ "@rollup/rollup-freebsd-arm64": "4.54.0",
1993
+ "@rollup/rollup-freebsd-x64": "4.54.0",
1994
+ "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
1995
+ "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
1996
+ "@rollup/rollup-linux-arm64-gnu": "4.54.0",
1997
+ "@rollup/rollup-linux-arm64-musl": "4.54.0",
1998
+ "@rollup/rollup-linux-loong64-gnu": "4.54.0",
1999
+ "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
2000
+ "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
2001
+ "@rollup/rollup-linux-riscv64-musl": "4.54.0",
2002
+ "@rollup/rollup-linux-s390x-gnu": "4.54.0",
2003
+ "@rollup/rollup-linux-x64-gnu": "4.54.0",
2004
+ "@rollup/rollup-linux-x64-musl": "4.54.0",
2005
+ "@rollup/rollup-openharmony-arm64": "4.54.0",
2006
+ "@rollup/rollup-win32-arm64-msvc": "4.54.0",
2007
+ "@rollup/rollup-win32-ia32-msvc": "4.54.0",
2008
+ "@rollup/rollup-win32-x64-gnu": "4.54.0",
2009
+ "@rollup/rollup-win32-x64-msvc": "4.54.0",
2010
+ "fsevents": "~2.3.2"
2011
+ }
2012
+ },
2013
+ "node_modules/scheduler": {
2014
+ "version": "0.27.0",
2015
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
2016
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
2017
+ "license": "MIT"
2018
+ },
2019
+ "node_modules/semver": {
2020
+ "version": "6.3.1",
2021
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2022
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2023
+ "dev": true,
2024
+ "license": "ISC",
2025
+ "bin": {
2026
+ "semver": "bin/semver.js"
2027
+ }
2028
+ },
2029
+ "node_modules/set-cookie-parser": {
2030
+ "version": "2.7.2",
2031
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
2032
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
2033
+ "license": "MIT"
2034
+ },
2035
+ "node_modules/source-map-js": {
2036
+ "version": "1.2.1",
2037
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2038
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2039
+ "dev": true,
2040
+ "license": "BSD-3-Clause",
2041
+ "engines": {
2042
+ "node": ">=0.10.0"
2043
+ }
2044
+ },
2045
+ "node_modules/tiny-invariant": {
2046
+ "version": "1.3.3",
2047
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
2048
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
2049
+ "license": "MIT"
2050
+ },
2051
+ "node_modules/tinyglobby": {
2052
+ "version": "0.2.15",
2053
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
2054
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
2055
+ "dev": true,
2056
+ "license": "MIT",
2057
+ "dependencies": {
2058
+ "fdir": "^6.5.0",
2059
+ "picomatch": "^4.0.3"
2060
+ },
2061
+ "engines": {
2062
+ "node": ">=12.0.0"
2063
+ },
2064
+ "funding": {
2065
+ "url": "https://github.com/sponsors/SuperchupuDev"
2066
+ }
2067
+ },
2068
+ "node_modules/typescript": {
2069
+ "version": "5.8.3",
2070
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
2071
+ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
2072
+ "dev": true,
2073
+ "license": "Apache-2.0",
2074
+ "bin": {
2075
+ "tsc": "bin/tsc",
2076
+ "tsserver": "bin/tsserver"
2077
+ },
2078
+ "engines": {
2079
+ "node": ">=14.17"
2080
+ }
2081
+ },
2082
+ "node_modules/undici-types": {
2083
+ "version": "6.21.0",
2084
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
2085
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
2086
+ "dev": true,
2087
+ "license": "MIT"
2088
+ },
2089
+ "node_modules/update-browserslist-db": {
2090
+ "version": "1.2.3",
2091
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2092
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2093
+ "dev": true,
2094
+ "funding": [
2095
+ {
2096
+ "type": "opencollective",
2097
+ "url": "https://opencollective.com/browserslist"
2098
+ },
2099
+ {
2100
+ "type": "tidelift",
2101
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2102
+ },
2103
+ {
2104
+ "type": "github",
2105
+ "url": "https://github.com/sponsors/ai"
2106
+ }
2107
+ ],
2108
+ "license": "MIT",
2109
+ "dependencies": {
2110
+ "escalade": "^3.2.0",
2111
+ "picocolors": "^1.1.1"
2112
+ },
2113
+ "bin": {
2114
+ "update-browserslist-db": "cli.js"
2115
+ },
2116
+ "peerDependencies": {
2117
+ "browserslist": ">= 4.21.0"
2118
+ }
2119
+ },
2120
+ "node_modules/use-sync-external-store": {
2121
+ "version": "1.6.0",
2122
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
2123
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
2124
+ "license": "MIT",
2125
+ "peerDependencies": {
2126
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
2127
+ }
2128
+ },
2129
+ "node_modules/victory-vendor": {
2130
+ "version": "37.3.6",
2131
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
2132
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
2133
+ "license": "MIT AND ISC",
2134
+ "dependencies": {
2135
+ "@types/d3-array": "^3.0.3",
2136
+ "@types/d3-ease": "^3.0.0",
2137
+ "@types/d3-interpolate": "^3.0.1",
2138
+ "@types/d3-scale": "^4.0.2",
2139
+ "@types/d3-shape": "^3.1.0",
2140
+ "@types/d3-time": "^3.0.0",
2141
+ "@types/d3-timer": "^3.0.0",
2142
+ "d3-array": "^3.1.6",
2143
+ "d3-ease": "^3.0.1",
2144
+ "d3-interpolate": "^3.0.1",
2145
+ "d3-scale": "^4.0.2",
2146
+ "d3-shape": "^3.1.0",
2147
+ "d3-time": "^3.0.0",
2148
+ "d3-timer": "^3.0.1"
2149
+ }
2150
+ },
2151
+ "node_modules/vite": {
2152
+ "version": "6.4.1",
2153
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
2154
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
2155
+ "dev": true,
2156
+ "license": "MIT",
2157
+ "peer": true,
2158
+ "dependencies": {
2159
+ "esbuild": "^0.25.0",
2160
+ "fdir": "^6.4.4",
2161
+ "picomatch": "^4.0.2",
2162
+ "postcss": "^8.5.3",
2163
+ "rollup": "^4.34.9",
2164
+ "tinyglobby": "^0.2.13"
2165
+ },
2166
+ "bin": {
2167
+ "vite": "bin/vite.js"
2168
+ },
2169
+ "engines": {
2170
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2171
+ },
2172
+ "funding": {
2173
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2174
+ },
2175
+ "optionalDependencies": {
2176
+ "fsevents": "~2.3.3"
2177
+ },
2178
+ "peerDependencies": {
2179
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2180
+ "jiti": ">=1.21.0",
2181
+ "less": "*",
2182
+ "lightningcss": "^1.21.0",
2183
+ "sass": "*",
2184
+ "sass-embedded": "*",
2185
+ "stylus": "*",
2186
+ "sugarss": "*",
2187
+ "terser": "^5.16.0",
2188
+ "tsx": "^4.8.1",
2189
+ "yaml": "^2.4.2"
2190
+ },
2191
+ "peerDependenciesMeta": {
2192
+ "@types/node": {
2193
+ "optional": true
2194
+ },
2195
+ "jiti": {
2196
+ "optional": true
2197
+ },
2198
+ "less": {
2199
+ "optional": true
2200
+ },
2201
+ "lightningcss": {
2202
+ "optional": true
2203
+ },
2204
+ "sass": {
2205
+ "optional": true
2206
+ },
2207
+ "sass-embedded": {
2208
+ "optional": true
2209
+ },
2210
+ "stylus": {
2211
+ "optional": true
2212
+ },
2213
+ "sugarss": {
2214
+ "optional": true
2215
+ },
2216
+ "terser": {
2217
+ "optional": true
2218
+ },
2219
+ "tsx": {
2220
+ "optional": true
2221
+ },
2222
+ "yaml": {
2223
+ "optional": true
2224
+ }
2225
+ }
2226
+ },
2227
+ "node_modules/yallist": {
2228
+ "version": "3.1.1",
2229
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2230
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2231
+ "dev": true,
2232
+ "license": "ISC"
2233
+ }
2234
+ }
2235
+ }
frontend/package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "etsy-profit-analytics",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react-dom": "^19.2.3",
13
+ "recharts": "^3.6.0",
14
+ "react": "^19.2.3",
15
+ "react-router-dom": "^7.11.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.14.0",
19
+ "@vitejs/plugin-react": "^5.0.0",
20
+ "typescript": "~5.8.2",
21
+ "vite": "^6.2.0"
22
+ }
23
+ }
frontend/pages/AdminDashboard.tsx ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useAuth } from '../contexts/AuthContext';
3
+ import { User } from '../types';
4
+ import { API_BASE_URL } from '../services/api';
5
+
6
+ export const PERMISSIONS_LIST = [
7
+ { id: 'view_dashboard', label: 'View Dashboard' },
8
+ { id: 'view_profit', label: 'View Profit Analytics' },
9
+ { id: 'view_orders', label: 'View Orders' },
10
+ { id: 'view_import', label: 'View Data Import' },
11
+ { id: 'perform_import', label: 'Perform Data Import' },
12
+ { id: 'view_shops', label: 'View Shops' },
13
+ { id: 'manage_shops', label: 'Manage Shops (Create/Edit/Delete)' },
14
+ { id: 'manage_settings', label: 'Manage Settings' },
15
+ ];
16
+
17
+ export const AdminDashboard: React.FC = () => {
18
+ const { token, isAdmin } = useAuth();
19
+ const [users, setUsers] = useState<User[]>([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState('');
22
+
23
+ // New user form state
24
+ const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user', permissions: [] as string[] });
25
+ const [createLoading, setCreateLoading] = useState(false);
26
+
27
+ // Edit permissions state
28
+ const [editingUser, setEditingUser] = useState<User | null>(null);
29
+ const [editPermissions, setEditPermissions] = useState<string[]>([]);
30
+
31
+ useEffect(() => {
32
+ fetchUsers();
33
+ }, []);
34
+
35
+ const fetchUsers = async () => {
36
+ try {
37
+ const response = await fetch(`${API_BASE_URL}/admin/users`, {
38
+ headers: {
39
+ 'Authorization': `Bearer ${token}`
40
+ }
41
+ });
42
+ if (!response.ok) throw new Error('Failed to fetch users');
43
+ const data = await response.json();
44
+ setUsers(data);
45
+ } catch (err: any) {
46
+ setError(err.message);
47
+ } finally {
48
+ setLoading(false);
49
+ }
50
+ };
51
+
52
+ const handleCreateUser = async (e: React.FormEvent) => {
53
+ e.preventDefault();
54
+ setCreateLoading(true);
55
+ setError('');
56
+
57
+ try {
58
+ // If role is admin, they usually imply full permissions, but we can save explicit ones too.
59
+ // If role is user, permissions are critical.
60
+
61
+ const payload = { ...newUser };
62
+ if (payload.role === 'admin') {
63
+ // Optionally force all permissions for admin, but user might want granular admin roles?
64
+ // User request implies "assign rights... to user".
65
+ // Admins generally have full access, but let's just send what is checked.
66
+ }
67
+
68
+ const response = await fetch(`${API_BASE_URL}/admin/users`, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'Authorization': `Bearer ${token}`
73
+ },
74
+ body: JSON.stringify(payload)
75
+ });
76
+
77
+ if (!response.ok) {
78
+ const data = await response.json();
79
+ throw new Error(data.detail || 'Failed to create user');
80
+ }
81
+
82
+ const createdUser = await response.json();
83
+ setUsers([...users, createdUser]);
84
+ setNewUser({ username: '', password: '', role: 'user', permissions: [] }); // Reset form
85
+ } catch (err: any) {
86
+ setError(err.message);
87
+ } finally {
88
+ setCreateLoading(false);
89
+ }
90
+ };
91
+
92
+ const handlePermissionChange = (permId: string, isChecked: boolean) => {
93
+ if (isChecked) {
94
+ setNewUser(prev => ({ ...prev, permissions: [...prev.permissions, permId] }));
95
+ } else {
96
+ setNewUser(prev => ({ ...prev, permissions: prev.permissions.filter(p => p !== permId) }));
97
+ }
98
+ };
99
+
100
+ const handleEditPermissionChange = (permId: string, isChecked: boolean) => {
101
+ if (isChecked) {
102
+ setEditPermissions(prev => [...prev, permId]);
103
+ } else {
104
+ setEditPermissions(prev => prev.filter(p => p !== permId));
105
+ }
106
+ };
107
+
108
+ const openEditModal = (user: User) => {
109
+ setEditingUser(user);
110
+ setEditPermissions(user.permissions || []);
111
+ };
112
+
113
+ const savePermissions = async () => {
114
+ if (!editingUser) return;
115
+ try {
116
+ const response = await fetch(`${API_BASE_URL}/admin/users/${editingUser.id}/permissions`, {
117
+ method: 'PUT',
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ 'Authorization': `Bearer ${token}`
121
+ },
122
+ body: JSON.stringify(editPermissions)
123
+ });
124
+
125
+ if (!response.ok) throw new Error('Failed to update permissions');
126
+
127
+ const updatedUser = await response.json();
128
+ setUsers(users.map(u => u.id === updatedUser.id ? updatedUser : u));
129
+ setEditingUser(null);
130
+ } catch (err: any) {
131
+ alert(err.message);
132
+ }
133
+ };
134
+
135
+ const handleDeleteUser = async (userId: number) => {
136
+ if (!window.confirm('Are you sure you want to delete this user?')) return;
137
+
138
+ try {
139
+ const response = await fetch(`${API_BASE_URL}/admin/users/${userId}`, {
140
+ method: 'DELETE',
141
+ headers: {
142
+ 'Authorization': `Bearer ${token}`
143
+ }
144
+ });
145
+
146
+ if (!response.ok) {
147
+ const data = await response.json();
148
+ throw new Error(data.detail || 'Failed to delete user');
149
+ }
150
+
151
+ setUsers(users.filter(u => u.id !== userId));
152
+ } catch (err: any) {
153
+ alert(err.message);
154
+ }
155
+ };
156
+
157
+ if (!isAdmin) {
158
+ return <div className="p-8 text-center text-red-600">Access Denied</div>;
159
+ }
160
+
161
+ return (
162
+ <div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
163
+ <h1 className="text-2xl font-semibold text-gray-900 mb-6">Admin User Management</h1>
164
+
165
+ {error && (
166
+ <div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
167
+ {error}
168
+ </div>
169
+ )}
170
+
171
+ {/* Create User Form */}
172
+ <div className="bg-white shadow rounded-lg p-6 mb-8">
173
+ <h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">Create New User</h3>
174
+ <form onSubmit={handleCreateUser} className="space-y-4">
175
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
176
+ <div>
177
+ <label htmlFor="username" className="block text-sm font-medium text-gray-700">Username</label>
178
+ <input
179
+ type="text"
180
+ name="username"
181
+ id="username"
182
+ required
183
+ className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
184
+ value={newUser.username}
185
+ onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
186
+ />
187
+ </div>
188
+ <div>
189
+ <label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
190
+ <input
191
+ type="password"
192
+ name="password"
193
+ id="password"
194
+ required
195
+ className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
196
+ value={newUser.password}
197
+ onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
198
+ />
199
+ </div>
200
+ <div>
201
+ <label htmlFor="role" className="block text-sm font-medium text-gray-700">Role</label>
202
+ <select
203
+ id="role"
204
+ name="role"
205
+ className="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
206
+ value={newUser.role}
207
+ onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
208
+ >
209
+ <option value="user">User</option>
210
+ <option value="admin">Admin</option>
211
+ </select>
212
+ </div>
213
+ </div>
214
+
215
+ {newUser.role === 'user' && (
216
+ <div className="mt-4">
217
+ <label className="block text-sm font-medium text-gray-700 mb-2">Permissions</label>
218
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
219
+ {PERMISSIONS_LIST.map(perm => (
220
+ <div key={perm.id} className="flex items-center">
221
+ <input
222
+ id={`perm-${perm.id}`}
223
+ type="checkbox"
224
+ checked={newUser.permissions.includes(perm.id)}
225
+ onChange={(e) => handlePermissionChange(perm.id, e.target.checked)}
226
+ className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
227
+ />
228
+ <label htmlFor={`perm-${perm.id}`} className="ml-2 block text-sm text-gray-900">
229
+ {perm.label}
230
+ </label>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ </div>
235
+ )}
236
+
237
+ <div className="flex justify-end mt-4">
238
+ <button
239
+ type="submit"
240
+ disabled={createLoading}
241
+ className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
242
+ >
243
+ {createLoading ? 'Creating...' : 'Create User'}
244
+ </button>
245
+ </div>
246
+ </form>
247
+ </div>
248
+
249
+ {/* Users List */}
250
+ <div className="flex flex-col">
251
+ <div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
252
+ <div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
253
+ <div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
254
+ <table className="min-w-full divide-y divide-gray-200">
255
+ <thead className="bg-gray-50">
256
+ <tr>
257
+ <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
258
+ Username
259
+ </th>
260
+ <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
261
+ Role
262
+ </th>
263
+ <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
264
+ Permissions
265
+ </th>
266
+ <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
267
+ Created At
268
+ </th>
269
+ <th scope="col" className="relative px-6 py-3">
270
+ <span className="sr-only">Actions</span>
271
+ </th>
272
+ </tr>
273
+ </thead>
274
+ <tbody className="bg-white divide-y divide-gray-200">
275
+ {loading ? (
276
+ <tr><td colSpan={5} className="text-center py-4">Loading...</td></tr>
277
+ ) : users.map((user) => (
278
+ <tr key={user.id}>
279
+ <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
280
+ {user.username}
281
+ </td>
282
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
283
+ <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-green-100 text-green-800'
284
+ }`}>
285
+ {user.role}
286
+ </span>
287
+ </td>
288
+ <td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
289
+ {user.role === 'admin' ? (
290
+ <span className="text-gray-400 italic">All Permissions</span>
291
+ ) : (
292
+ <span title={user.permissions?.join(', ')}>
293
+ {user.permissions?.length ? `${user.permissions.length} active` : 'None'}
294
+ </span>
295
+ )}
296
+ </td>
297
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
298
+ {new Date(user.created_at).toLocaleDateString()}
299
+ </td>
300
+ <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
301
+ <button
302
+ onClick={() => openEditModal(user)}
303
+ className="text-indigo-600 hover:text-indigo-900 mr-4"
304
+ >
305
+ Edit Rights
306
+ </button>
307
+ <button
308
+ onClick={() => handleDeleteUser(user.id)}
309
+ className="text-red-600 hover:text-red-900"
310
+ >
311
+ Delete
312
+ </button>
313
+ </td>
314
+ </tr>
315
+ ))}
316
+ </tbody>
317
+ </table>
318
+ </div>
319
+ </div>
320
+ </div>
321
+ </div>
322
+
323
+ {/* Edit Permissions Modal */}
324
+ {editingUser && (
325
+ <div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
326
+ <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
327
+ <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onClick={() => setEditingUser(null)}></div>
328
+ <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
329
+ <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
330
+ <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
331
+ <div className="sm:flex sm:items-start">
332
+ <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
333
+ <h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
334
+ Edit Permissions for {editingUser.username}
335
+ </h3>
336
+ <div className="mt-4">
337
+ <p className="text-sm text-gray-500 mb-4">Select allowed actions for this user.</p>
338
+ <div className="space-y-2">
339
+ {PERMISSIONS_LIST.map(perm => (
340
+ <div key={perm.id} className="flex items-center">
341
+ <input
342
+ id={`edit-perm-${perm.id}`}
343
+ type="checkbox"
344
+ checked={editPermissions.includes(perm.id)}
345
+ onChange={(e) => handleEditPermissionChange(perm.id, e.target.checked)}
346
+ className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
347
+ />
348
+ <label htmlFor={`edit-perm-${perm.id}`} className="ml-2 block text-sm text-gray-900">
349
+ {perm.label}
350
+ </label>
351
+ </div>
352
+ ))}
353
+ </div>
354
+ </div>
355
+ </div>
356
+ </div>
357
+ </div>
358
+ <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
359
+ <button
360
+ type="button"
361
+ onClick={savePermissions}
362
+ className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm"
363
+ >
364
+ Save Changes
365
+ </button>
366
+ <button
367
+ type="button"
368
+ onClick={() => setEditingUser(null)}
369
+ className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
370
+ >
371
+ Cancel
372
+ </button>
373
+ </div>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ )}
378
+ </div>
379
+ );
380
+ };
frontend/pages/Dashboard.tsx ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useMemo } from 'react';
2
+ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend } from 'recharts';
3
+ import { fetchProfitData, fetchAllShopsSummary, fetchShopSummary } from '../services/api';
4
+ import { ProfitData, AllShopsSummaryResponse, ShopSummaryResponse } from '../types';
5
+ import { useShop } from '../contexts/ShopContext';
6
+
7
+ // Color palette for shops
8
+ const SHOP_COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#f97316', '#eab308'];
9
+
10
+ // Time period presets - Main buttons
11
+ const MAIN_PRESETS = [
12
+ { label: 'Today', value: 'today' },
13
+ { label: 'Yesterday', value: 'yesterday' },
14
+ { label: 'Last 7 days', value: '7d' },
15
+ { label: 'Last 30 days', value: '30d' },
16
+ { label: 'All Time', value: 'all' },
17
+ ];
18
+
19
+ // Quick select options in custom picker
20
+ const QUICK_SELECT_OPTIONS = [
21
+ { label: 'Last 90 days', value: '90d' },
22
+ { label: 'This Year', value: 'ytd' },
23
+ { label: 'Last Year', value: 'lastyear' },
24
+ ];
25
+
26
+ // Helper to get date range from preset
27
+ const getDateRangeFromPreset = (preset: string): { startDate: string; endDate: string } | null => {
28
+ const today = new Date();
29
+ const formatDate = (d: Date) => d.toISOString().split('T')[0];
30
+
31
+ switch (preset) {
32
+ case 'today':
33
+ return { startDate: formatDate(today), endDate: formatDate(today) };
34
+ case 'yesterday': {
35
+ const yesterday = new Date(today);
36
+ yesterday.setDate(yesterday.getDate() - 1);
37
+ return { startDate: formatDate(yesterday), endDate: formatDate(yesterday) };
38
+ }
39
+ case '7d': {
40
+ const start = new Date(today);
41
+ start.setDate(start.getDate() - 6);
42
+ return { startDate: formatDate(start), endDate: formatDate(today) };
43
+ }
44
+ case '30d': {
45
+ const start = new Date(today);
46
+ start.setDate(start.getDate() - 29);
47
+ return { startDate: formatDate(start), endDate: formatDate(today) };
48
+ }
49
+ case '90d': {
50
+ const start = new Date(today);
51
+ start.setDate(start.getDate() - 89);
52
+ return { startDate: formatDate(start), endDate: formatDate(today) };
53
+ }
54
+ case 'ytd': {
55
+ const start = new Date(today.getFullYear(), 0, 1);
56
+ return { startDate: formatDate(start), endDate: formatDate(today) };
57
+ }
58
+ case 'lastyear': {
59
+ const start = new Date(today.getFullYear() - 1, 0, 1);
60
+ const end = new Date(today.getFullYear() - 1, 11, 31);
61
+ return { startDate: formatDate(start), endDate: formatDate(end) };
62
+ }
63
+ case 'all':
64
+ default:
65
+ return null; // null means all time - no date filter
66
+ }
67
+ };
68
+
69
+ const Dashboard: React.FC = () => {
70
+ const { currentShop, isLoading: shopLoading, isAllShopsMode, shops } = useShop();
71
+ // State for single shop raw data (for backwards compatibility/charts if needed)
72
+ const [data, setData] = useState<ProfitData[]>([]);
73
+ // State for single shop summary
74
+ const [shopSummary, setShopSummary] = useState<ShopSummaryResponse | null>(null);
75
+ // State for all shops summary
76
+ const [allShopsData, setAllShopsData] = useState<AllShopsSummaryResponse | null>(null);
77
+ const [loading, setLoading] = useState(true);
78
+
79
+ // Time period state
80
+ const [selectedPreset, setSelectedPreset] = useState('30d');
81
+ const [customStartDate, setCustomStartDate] = useState('');
82
+ const [customEndDate, setCustomEndDate] = useState('');
83
+ const [showCustomPicker, setShowCustomPicker] = useState(false);
84
+
85
+ // Load data when preset changes
86
+ useEffect(() => {
87
+ const loadData = async () => {
88
+ setLoading(true);
89
+ try {
90
+ let startDate: string | undefined;
91
+ let endDate: string | undefined;
92
+
93
+ if (selectedPreset === 'custom' && customStartDate && customEndDate) {
94
+ startDate = customStartDate;
95
+ endDate = customEndDate;
96
+ } else if (selectedPreset !== 'all') {
97
+ const range = getDateRangeFromPreset(selectedPreset);
98
+ if (range) {
99
+ startDate = range.startDate;
100
+ endDate = range.endDate;
101
+ }
102
+ }
103
+
104
+ if (isAllShopsMode) {
105
+ const result = await fetchAllShopsSummary(startDate, endDate);
106
+ setAllShopsData(result);
107
+ setData([]);
108
+ setShopSummary(null);
109
+ } else if (currentShop) {
110
+ // Fetch both raw data (for charts) and summary (for KPIs)
111
+ const [summaryResult, rawDataResult] = await Promise.all([
112
+ fetchShopSummary(currentShop.id, startDate, endDate),
113
+ fetchProfitData(currentShop.id)
114
+ ]);
115
+ setShopSummary(summaryResult);
116
+ setData(rawDataResult);
117
+ setAllShopsData(null);
118
+ } else {
119
+ setData([]);
120
+ setAllShopsData(null);
121
+ setShopSummary(null);
122
+ }
123
+ } catch (e) {
124
+ console.error("Failed to load dashboard data", e);
125
+ } finally {
126
+ setLoading(false);
127
+ }
128
+ };
129
+ loadData();
130
+ }, [currentShop, isAllShopsMode, selectedPreset, customStartDate, customEndDate]);
131
+
132
+ // Filter raw data for single shop charts based on date range
133
+ const filteredData = useMemo(() => {
134
+ let startDate: Date | null = null;
135
+ let endDate: Date | null = null;
136
+
137
+ if (selectedPreset === 'custom' && customStartDate && customEndDate) {
138
+ startDate = new Date(customStartDate);
139
+ endDate = new Date(customEndDate);
140
+ } else if (selectedPreset !== 'all') {
141
+ const range = getDateRangeFromPreset(selectedPreset);
142
+ if (range) {
143
+ startDate = new Date(range.startDate);
144
+ endDate = new Date(range.endDate);
145
+ }
146
+ }
147
+
148
+ if (!startDate || !endDate) return data;
149
+
150
+ return data.filter(item => {
151
+ if (!item.sale_date) return false;
152
+ const itemDate = new Date(item.sale_date);
153
+ return itemDate >= startDate! && itemDate <= endDate!;
154
+ });
155
+ }, [data, selectedPreset, customStartDate, customEndDate]);
156
+
157
+ // Aggregate stats from summary or raw data
158
+ const aggregates = useMemo(() => {
159
+ if (shopSummary) {
160
+ return {
161
+ revenue: shopSummary.aggregates.total_revenue,
162
+ profit: shopSummary.aggregates.total_profit,
163
+ orders: shopSummary.aggregates.total_orders,
164
+ margin: shopSummary.aggregates.profit_margin,
165
+ fulfillmentRate: shopSummary.aggregates.fulfillment_rate
166
+ };
167
+ }
168
+ // Fallback if summary not available
169
+ return filteredData.reduce((acc, curr) => ({
170
+ revenue: acc.revenue + curr.revenue,
171
+ profit: acc.profit + curr.profit,
172
+ orders: acc.orders + 1,
173
+ margin: 0,
174
+ fulfillmentRate: 0
175
+ }), { revenue: 0, profit: 0, orders: 0, margin: 0, fulfillmentRate: 0 });
176
+ }, [shopSummary, filteredData]);
177
+
178
+ // Changes from summary
179
+ const changes = useMemo(() => {
180
+ if (shopSummary) {
181
+ return shopSummary.changes;
182
+ }
183
+ return null;
184
+ }, [shopSummary]);
185
+
186
+ const chartData = useMemo(() => {
187
+ const grouped = filteredData.reduce((acc, curr) => {
188
+ const date = curr.sale_date?.substring(5) || 'N/A';
189
+ if (!acc[date]) acc[date] = { name: date, revenue: 0 };
190
+ acc[date].revenue += curr.revenue;
191
+ return acc;
192
+ }, {} as Record<string, any>);
193
+ return Object.values(grouped).reverse().slice(0, selectedPreset === '7d' ? 7 : selectedPreset === '30d' ? 30 : 999);
194
+ }, [filteredData, selectedPreset]);
195
+
196
+ const formatCurrency = (val: number) => `$${val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
197
+
198
+ const handlePresetChange = (preset: string) => {
199
+ setSelectedPreset(preset);
200
+ if (preset !== 'custom') {
201
+ setShowCustomPicker(false);
202
+ }
203
+ };
204
+
205
+ const handleQuickSelect = (value: string) => {
206
+ setSelectedPreset(value);
207
+ setShowCustomPicker(false);
208
+ };
209
+
210
+ const applyCustomDates = () => {
211
+ if (customStartDate && customEndDate) {
212
+ setSelectedPreset('custom');
213
+ setShowCustomPicker(false);
214
+ }
215
+ };
216
+
217
+ if (shopLoading || loading) {
218
+ return (
219
+ <div className="flex h-full items-center justify-center min-h-[400px]">
220
+ <div className="flex flex-col items-center gap-2">
221
+ <span className="material-symbols-outlined text-4xl text-primary animate-spin">progress_activity</span>
222
+ <p className="text-text-secondary">Loading dashboard...</p>
223
+ </div>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ if (!currentShop && !isAllShopsMode) {
229
+ return (
230
+ <div className="flex h-full items-center justify-center min-h-[400px]">
231
+ <div className="flex flex-col items-center gap-4 text-center">
232
+ <div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center">
233
+ <span className="material-symbols-outlined text-3xl text-amber-600">storefront</span>
234
+ </div>
235
+ <h2 className="text-xl font-bold text-text-main">No Shop Selected</h2>
236
+ <p className="text-text-secondary max-w-md">
237
+ Please select a shop from the sidebar or create a new one to view the dashboard.
238
+ </p>
239
+ <a
240
+ href="#/shops"
241
+ className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors"
242
+ >
243
+ <span className="material-symbols-outlined">add</span>
244
+ Manage Shops
245
+ </a>
246
+ </div>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ // ALL SHOPS MODE VIEW
252
+ if (isAllShopsMode && allShopsData) {
253
+ const { aggregates: agg, shops: shopList, best_performer, changes: allShopChanges } = allShopsData;
254
+
255
+ // Pie chart data for revenue distribution
256
+ const pieData = shopList.map((shop, idx) => ({
257
+ name: shop.shop_name,
258
+ value: shop.total_revenue,
259
+ color: SHOP_COLORS[idx % SHOP_COLORS.length]
260
+ }));
261
+
262
+ // Bar chart data for comparison
263
+ const barData = shopList.map((shop, idx) => ({
264
+ name: shop.shop_name.length > 12 ? shop.shop_name.substring(0, 12) + '...' : shop.shop_name,
265
+ revenue: shop.total_revenue,
266
+ profit: shop.total_profit,
267
+ cost: shop.total_cost,
268
+ fill: SHOP_COLORS[idx % SHOP_COLORS.length]
269
+ }));
270
+
271
+ return (
272
+ <div className="max-w-[1400px] mx-auto flex flex-col gap-6">
273
+ {/* Header */}
274
+ <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
275
+ <div>
276
+ <div className="flex items-center gap-2 mb-1">
277
+ <span className="px-3 py-1 text-xs font-semibold bg-gradient-to-r from-purple-100 to-indigo-100 text-purple-700 rounded-full border border-purple-200">
278
+ 📊 All Shops Overview
279
+ </span>
280
+ </div>
281
+ <h2 className="text-3xl font-bold text-text-main tracking-tight">Multi-Shop Dashboard</h2>
282
+ <p className="text-text-secondary mt-1">Aggregated metrics and comparison across {agg.shop_count} shops.</p>
283
+ </div>
284
+
285
+ {/* Time Period Selector - Component */}
286
+ <TimeSelector
287
+ selectedPreset={selectedPreset}
288
+ handlePresetChange={handlePresetChange}
289
+ showCustomPicker={showCustomPicker}
290
+ setShowCustomPicker={setShowCustomPicker}
291
+ handleQuickSelect={handleQuickSelect}
292
+ customStartDate={customStartDate}
293
+ setCustomStartDate={setCustomStartDate}
294
+ customEndDate={customEndDate}
295
+ setCustomEndDate={setCustomEndDate}
296
+ applyCustomDates={applyCustomDates}
297
+ />
298
+ </div>
299
+
300
+ {/* Aggregated Stats Grid */}
301
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
302
+ <StatCard
303
+ title="Total Revenue"
304
+ value={formatCurrency(agg.total_revenue)}
305
+ subtitle={`${agg.shop_count} shops`}
306
+ icon="payments"
307
+ colorClass="text-green-600 bg-green-50 border-green-100"
308
+ changePercent={allShopChanges?.revenue_change}
309
+ />
310
+ <StatCard
311
+ title="Total Orders"
312
+ value={agg.total_orders.toLocaleString()}
313
+ subtitle={`${agg.total_fulfilled} fulfilled`}
314
+ icon="shopping_cart"
315
+ colorClass="text-primary bg-orange-50 border-orange-100"
316
+ changePercent={allShopChanges?.orders_change}
317
+ />
318
+ <StatCard
319
+ title="Total Profit"
320
+ value={formatCurrency(agg.total_profit)}
321
+ subtitle={`${agg.overall_margin}% margin`}
322
+ icon="account_balance_wallet"
323
+ colorClass="text-purple-600 bg-purple-50 border-purple-100"
324
+ changePercent={allShopChanges?.profit_change}
325
+ />
326
+ <StatCard
327
+ title="Fulfillment Rate"
328
+ value={`${agg.overall_fulfillment_rate}%`}
329
+ subtitle={`${agg.total_fulfilled}/${agg.total_orders}`}
330
+ icon="local_shipping"
331
+ colorClass="text-blue-600 bg-blue-50 border-blue-100"
332
+ changePercent={allShopChanges?.fulfillment_rate_change}
333
+ />
334
+ </div>
335
+
336
+ {/* Top Performer Card Only */}
337
+ {best_performer && (
338
+ <div className="grid grid-cols-1 md:grid-cols-1 gap-4">
339
+ <div className="bg-gradient-to-r from-emerald-50 to-green-50 rounded-xl p-5 border border-emerald-200">
340
+ <div className="flex items-center gap-3">
341
+ <div className="w-12 h-12 rounded-full bg-emerald-500 flex items-center justify-center">
342
+ <span className="material-symbols-outlined text-white text-2xl">emoji_events</span>
343
+ </div>
344
+ <div>
345
+ <p className="text-xs text-emerald-600 font-medium uppercase tracking-wide">Top Performer</p>
346
+ <p className="text-lg font-bold text-emerald-800">{best_performer.shop_name}</p>
347
+ <p className="text-sm text-emerald-600">
348
+ {formatCurrency(best_performer.total_profit)} profit • {best_performer.profit_margin}% margin
349
+ </p>
350
+ </div>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ )}
355
+
356
+ {/* Charts Row */}
357
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
358
+ {/* Revenue Comparison Bar Chart */}
359
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
360
+ <h3 className="text-lg font-bold text-text-main mb-6">Shop Revenue Comparison</h3>
361
+ <div className="h-[300px]">
362
+ {barData.length > 0 ? (
363
+ <ResponsiveContainer width="100%" height="100%">
364
+ <BarChart data={barData} layout="vertical" margin={{ left: 20 }}>
365
+ <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#e2e8f0" />
366
+ <XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} tickFormatter={(val) => `$${(val / 1000).toFixed(0)}k`} />
367
+ <YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} width={100} />
368
+ <Tooltip formatter={(val: number) => formatCurrency(val)} />
369
+ <Bar dataKey="revenue" radius={[0, 4, 4, 0]} />
370
+ </BarChart>
371
+ </ResponsiveContainer>
372
+ ) : (
373
+ <div className="flex items-center justify-center h-full text-text-secondary">
374
+ <p>No shop data available</p>
375
+ </div>
376
+ )}
377
+ </div>
378
+ </div>
379
+
380
+ {/* Revenue Distribution Pie Chart */}
381
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
382
+ <h3 className="text-lg font-bold text-text-main mb-6">Revenue Distribution</h3>
383
+ <div className="h-[300px]">
384
+ {pieData.length > 0 ? (
385
+ <ResponsiveContainer width="100%" height="100%">
386
+ <PieChart>
387
+ <Pie
388
+ data={pieData}
389
+ cx="50%"
390
+ cy="50%"
391
+ innerRadius={60}
392
+ outerRadius={100}
393
+ paddingAngle={2}
394
+ dataKey="value"
395
+ label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`}
396
+ labelLine={false}
397
+ >
398
+ {pieData.map((entry, index) => (
399
+ <Cell key={`cell-${index}`} fill={entry.color} />
400
+ ))}
401
+ </Pie>
402
+ <Tooltip formatter={(val: number) => formatCurrency(val)} />
403
+ </PieChart>
404
+ </ResponsiveContainer>
405
+ ) : (
406
+ <div className="flex items-center justify-center h-full text-text-secondary">
407
+ <p>No data available</p>
408
+ </div>
409
+ )}
410
+ </div>
411
+ </div>
412
+ </div>
413
+
414
+ {/* Shop Leaderboard Table */}
415
+ <div className="bg-white rounded-xl border border-border-light shadow-sm overflow-hidden">
416
+ <div className="p-6 border-b border-border-light bg-gradient-to-r from-slate-50 to-white">
417
+ <h3 className="text-lg font-bold text-text-main">Shop Performance Leaderboard</h3>
418
+ <p className="text-sm text-text-secondary">Ranked by profit (highest to lowest)</p>
419
+ </div>
420
+ <div className="overflow-x-auto">
421
+ <table className="w-full text-sm text-left">
422
+ <thead className="text-xs text-text-secondary uppercase bg-slate-50 border-b border-border-light">
423
+ <tr>
424
+ <th className="px-6 py-4 font-semibold">Rank</th>
425
+ <th className="px-6 py-4 font-semibold">Shop</th>
426
+ <th className="px-6 py-4 font-semibold text-right">Orders</th>
427
+ <th className="px-6 py-4 font-semibold text-right">Revenue</th>
428
+ <th className="px-6 py-4 font-semibold text-right">Cost</th>
429
+ <th className="px-6 py-4 font-semibold text-right">Profit</th>
430
+ <th className="px-6 py-4 font-semibold text-center">Margin</th>
431
+ <th className="px-6 py-4 font-semibold text-center">Fulfillment</th>
432
+ </tr>
433
+ </thead>
434
+ <tbody className="divide-y divide-border-light">
435
+ {shopList.map((shop, idx) => (
436
+ <tr key={shop.shop_id} className="hover:bg-slate-50 transition-colors">
437
+ <td className="px-6 py-4">
438
+ <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${idx === 0 ? 'bg-yellow-100 text-yellow-700' :
439
+ idx === 1 ? 'bg-slate-200 text-slate-600' :
440
+ idx === 2 ? 'bg-orange-100 text-orange-700' :
441
+ 'bg-slate-100 text-slate-500'
442
+ }`}>
443
+ {idx + 1}
444
+ </div>
445
+ </td>
446
+ <td className="px-6 py-4">
447
+ <div className="flex items-center gap-2">
448
+ <div className="w-3 h-3 rounded-full" style={{ backgroundColor: SHOP_COLORS[idx % SHOP_COLORS.length] }}></div>
449
+ <span className="font-medium text-text-main">{shop.shop_name}</span>
450
+ </div>
451
+ </td>
452
+ <td className="px-6 py-4 text-right">{shop.order_count}</td>
453
+ <td className="px-6 py-4 text-right font-medium">{formatCurrency(shop.total_revenue)}</td>
454
+ <td className="px-6 py-4 text-right text-text-secondary">{formatCurrency(shop.total_cost)}</td>
455
+ <td className={`px-6 py-4 text-right font-bold ${shop.total_profit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
456
+ {formatCurrency(shop.total_profit)}
457
+ </td>
458
+ <td className="px-6 py-4 text-center">
459
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${shop.profit_margin >= 30 ? 'bg-green-100 text-green-700' :
460
+ shop.profit_margin >= 15 ? 'bg-yellow-100 text-yellow-700' :
461
+ 'bg-red-100 text-red-700'
462
+ }`}>
463
+ {shop.profit_margin}%
464
+ </span>
465
+ </td>
466
+ <td className="px-6 py-4 text-center">
467
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${shop.fulfillment_rate >= 80 ? 'bg-green-100 text-green-700' :
468
+ shop.fulfillment_rate >= 50 ? 'bg-yellow-100 text-yellow-700' :
469
+ 'bg-red-100 text-red-700'
470
+ }`}>
471
+ {shop.fulfillment_rate}%
472
+ </span>
473
+ </td>
474
+ </tr>
475
+ ))}
476
+ </tbody>
477
+ </table>
478
+ </div>
479
+ </div>
480
+ </div>
481
+ );
482
+ }
483
+
484
+ // SINGLE SHOP MODE VIEW
485
+ const shopChanges = changes;
486
+
487
+ return (
488
+ <div className="max-w-[1280px] mx-auto flex flex-col gap-6">
489
+ <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
490
+ <div>
491
+ <div className="flex items-center gap-2 mb-1">
492
+ <span className="px-2 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded-full">
493
+ {currentShop?.name}
494
+ </span>
495
+ </div>
496
+ <h2 className="text-3xl font-bold text-text-main tracking-tight">Overview</h2>
497
+ <p className="text-text-secondary mt-1">Here's what's happening with your shop.</p>
498
+ </div>
499
+
500
+ {/* Time Period Selector - Component */}
501
+ <TimeSelector
502
+ selectedPreset={selectedPreset}
503
+ handlePresetChange={handlePresetChange}
504
+ showCustomPicker={showCustomPicker}
505
+ setShowCustomPicker={setShowCustomPicker}
506
+ handleQuickSelect={handleQuickSelect}
507
+ customStartDate={customStartDate}
508
+ setCustomStartDate={setCustomStartDate}
509
+ customEndDate={customEndDate}
510
+ setCustomEndDate={setCustomEndDate}
511
+ applyCustomDates={applyCustomDates}
512
+ />
513
+ </div>
514
+
515
+ {/* Stats Grid */}
516
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
517
+ <StatCard
518
+ title="Total Revenue"
519
+ value={formatCurrency(aggregates.revenue)}
520
+ icon="payments"
521
+ colorClass="text-green-600 bg-green-50 border-green-100"
522
+ changePercent={shopChanges?.revenue_change}
523
+ />
524
+ <StatCard
525
+ title="Total Orders"
526
+ value={aggregates.orders.toLocaleString()}
527
+ icon="shopping_cart"
528
+ colorClass="text-primary bg-orange-50 border-orange-100"
529
+ changePercent={shopChanges?.orders_change}
530
+ />
531
+ <StatCard
532
+ title="Net Profit"
533
+ value={formatCurrency(aggregates.profit)}
534
+ icon="account_balance_wallet"
535
+ colorClass="text-purple-600 bg-purple-50 border-purple-100"
536
+ changePercent={shopChanges?.profit_change}
537
+ />
538
+ <StatCard
539
+ title="Fulfillment Rate"
540
+ value={`${aggregates.fulfillmentRate || 0}%`}
541
+ icon="local_shipping"
542
+ colorClass="text-blue-600 bg-blue-50 border-blue-100"
543
+ changePercent={shopChanges?.fulfillment_rate_change}
544
+ />
545
+ </div>
546
+
547
+ <div className="grid grid-cols-1 lg:grid-cols-1 gap-6">
548
+ {/* Main Chart */}
549
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
550
+ <div className="flex justify-between items-center mb-6">
551
+ <div>
552
+ <h3 className="text-lg font-bold text-text-main">Sales Performance</h3>
553
+ <p className="text-sm text-text-secondary">Revenue over time ({selectedPreset === 'custom' ? 'Custom Range' : MAIN_PRESETS.find(p => p.value === selectedPreset)?.label})</p>
554
+ </div>
555
+ </div>
556
+ <div className="h-[300px] w-full">
557
+ {chartData.length > 0 ? (
558
+ <ResponsiveContainer width="100%" height="100%">
559
+ <AreaChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
560
+ <defs>
561
+ <linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
562
+ <stop offset="5%" stopColor="#137fec" stopOpacity={0.2} />
563
+ <stop offset="95%" stopColor="#137fec" stopOpacity={0} />
564
+ </linearGradient>
565
+ </defs>
566
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
567
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} dy={10} />
568
+ <YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
569
+ <Tooltip
570
+ contentStyle={{ backgroundColor: '#fff', border: '1px solid #e2e8f0', borderRadius: '8px' }}
571
+ itemStyle={{ color: '#1e293b' }}
572
+ formatter={(val: number) => formatCurrency(val)}
573
+ />
574
+ <Area type="monotone" dataKey="revenue" stroke="#137fec" strokeWidth={3} fillOpacity={1} fill="url(#colorRevenue)" />
575
+ </AreaChart>
576
+ </ResponsiveContainer>
577
+ ) : (
578
+ <div className="flex items-center justify-center h-full text-text-secondary">
579
+ <div className="text-center">
580
+ <span className="material-symbols-outlined text-4xl mb-2">bar_chart</span>
581
+ <p>No data available for this period.</p>
582
+ </div>
583
+ </div>
584
+ )}
585
+ </div>
586
+ </div>
587
+ </div>
588
+ </div>
589
+ );
590
+ };
591
+
592
+ interface StatCardProps {
593
+ title: string;
594
+ value: string;
595
+ subtitle?: string;
596
+ icon: string;
597
+ colorClass: string;
598
+ changePercent?: number | null;
599
+ }
600
+
601
+ const StatCard = ({ title, value, subtitle, icon, colorClass, changePercent }: StatCardProps) => (
602
+ <div className="bg-white rounded-xl p-5 border border-border-light hover:border-slate-300 transition-colors shadow-sm">
603
+ <div className="flex justify-between items-start mb-4">
604
+ <div className={`p-2 rounded-lg border ${colorClass}`}>
605
+ <span className="material-symbols-outlined">{icon}</span>
606
+ </div>
607
+ </div>
608
+ <p className="text-text-secondary text-sm font-medium">{title}</p>
609
+ <h3 className="text-2xl font-bold text-text-main mt-1">{value}</h3>
610
+ {subtitle && <p className="text-xs text-text-secondary mt-1">{subtitle}</p>}
611
+ {changePercent !== undefined && changePercent !== null && (
612
+ <div className={`flex items-center gap-1 mt-2 text-xs font-medium ${changePercent >= 0 ? 'text-green-600' : 'text-red-500'}`}>
613
+ <span className="material-symbols-outlined text-[14px]">
614
+ {changePercent >= 0 ? 'trending_up' : 'trending_down'}
615
+ </span>
616
+ <span>{changePercent >= 0 ? '+' : ''}{changePercent.toFixed(1)}% vs previous period</span>
617
+ </div>
618
+ )}
619
+ </div>
620
+ );
621
+
622
+ // Extracted TimeSelector to reuse in both views
623
+ const TimeSelector = ({ selectedPreset, handlePresetChange, showCustomPicker, setShowCustomPicker, handleQuickSelect, customStartDate, setCustomStartDate, customEndDate, setCustomEndDate, applyCustomDates }: any) => {
624
+ return (
625
+ <div className="flex flex-col gap-2">
626
+ <div className="flex items-center gap-2 flex-wrap">
627
+ {MAIN_PRESETS.map((preset) => (
628
+ <button
629
+ key={preset.value}
630
+ onClick={() => handlePresetChange(preset.value)}
631
+ className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-all ${selectedPreset === preset.value
632
+ ? 'bg-primary text-white shadow-md'
633
+ : 'bg-white border border-border-light text-text-main hover:bg-slate-50'
634
+ }`}
635
+ >
636
+ {preset.label}
637
+ </button>
638
+ ))}
639
+ <div className="relative">
640
+ <button
641
+ onClick={() => setShowCustomPicker(!showCustomPicker)}
642
+ className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-all flex items-center gap-1 ${selectedPreset === 'custom' || QUICK_SELECT_OPTIONS.some(o => o.value === selectedPreset)
643
+ ? 'bg-primary text-white shadow-md'
644
+ : 'bg-white border border-border-light text-text-main hover:bg-slate-50'
645
+ }`}
646
+ >
647
+ <span className="material-symbols-outlined text-[16px]">calendar_month</span>
648
+ {selectedPreset === 'custom' ? 'Custom' :
649
+ QUICK_SELECT_OPTIONS.find(o => o.value === selectedPreset)?.label || 'More'}
650
+ </button>
651
+
652
+ {/* Custom Picker Dropdown */}
653
+ {showCustomPicker && (
654
+ <div className="absolute right-0 top-full mt-2 bg-white border border-border-light rounded-xl shadow-lg p-4 z-50 min-w-[300px]">
655
+ <p className="text-xs font-semibold text-text-secondary uppercase mb-2">Quick Select</p>
656
+ <div className="flex flex-wrap gap-2 mb-4">
657
+ {QUICK_SELECT_OPTIONS.map((opt) => (
658
+ <button
659
+ key={opt.value}
660
+ onClick={() => handleQuickSelect(opt.value)}
661
+ className="px-3 py-1.5 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
662
+ >
663
+ {opt.label}
664
+ </button>
665
+ ))}
666
+ </div>
667
+ <p className="text-xs font-semibold text-text-secondary uppercase mb-2">Custom Range</p>
668
+ <div className="flex items-center gap-2 mb-3">
669
+ <input
670
+ type="date"
671
+ value={customStartDate}
672
+ onChange={(e) => setCustomStartDate(e.target.value)}
673
+ className="flex-1 px-2 py-1.5 text-sm border border-border-light rounded-lg focus:outline-none focus:border-primary"
674
+ />
675
+ <span className="text-text-secondary text-sm">to</span>
676
+ <input
677
+ type="date"
678
+ value={customEndDate}
679
+ onChange={(e) => setCustomEndDate(e.target.value)}
680
+ className="flex-1 px-2 py-1.5 text-sm border border-border-light rounded-lg focus:outline-none focus:border-primary"
681
+ />
682
+ </div>
683
+ <button
684
+ onClick={applyCustomDates}
685
+ disabled={!customStartDate || !customEndDate}
686
+ className="w-full py-2 bg-primary text-white text-sm font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary/90"
687
+ >
688
+ Apply
689
+ </button>
690
+ </div>
691
+ )}
692
+ </div>
693
+ </div>
694
+ </div>
695
+ )
696
+ }
697
+
698
+ export default Dashboard;
frontend/pages/DataImport.tsx ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect, useMemo } from 'react';
3
+ import { uploadFile, fetchProviders, fetchImportHistory, deleteImport, fetchImportDetails, createProvider } from '../services/api';
4
+ import { FulfillmentProvider, ImportHistory } from '../types';
5
+ import { useShop } from '../contexts/ShopContext';
6
+
7
+ const parseCSV = (str: string) => {
8
+ const arr: string[][] = [];
9
+ let quote = false;
10
+ let row: string[] = [];
11
+ let col = '';
12
+
13
+ str = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
14
+
15
+ for (let i = 0; i < str.length; i++) {
16
+ let c = str[i];
17
+ if (quote) {
18
+ if (c === '"') {
19
+ if (str[i + 1] === '"') {
20
+ col += '"';
21
+ i++;
22
+ } else {
23
+ quote = false;
24
+ }
25
+ } else {
26
+ col += c;
27
+ }
28
+ } else {
29
+ if (c === '"') {
30
+ quote = true;
31
+ } else if (c === ',') {
32
+ row.push(col);
33
+ col = '';
34
+ } else if (c === '\n') {
35
+ row.push(col);
36
+ arr.push(row);
37
+ row = [];
38
+ col = '';
39
+ } else {
40
+ col += c;
41
+ }
42
+ }
43
+ }
44
+ if (col || row.length > 0) {
45
+ row.push(col);
46
+ arr.push(row);
47
+ }
48
+ return arr;
49
+ };
50
+
51
+ const CSVPreview: React.FC<{ content: string }> = ({ content }) => {
52
+ const rows = useMemo(() => parseCSV(content), [content]);
53
+
54
+ if (!rows.length) return <div className="p-6 text-center text-text-secondary">Empty file</div>;
55
+
56
+ const header = rows[0];
57
+ const body = rows.slice(1).filter(r => r.some(c => c.trim() !== ''));
58
+
59
+ return (
60
+ <div className="w-full h-full overflow-auto">
61
+ <table className="min-w-full text-left text-sm border-collapse">
62
+ <thead className="bg-slate-100 sticky top-0 z-10 shadow-sm">
63
+ <tr>
64
+ {header.map((h, i) => (
65
+ <th key={i} className="px-4 py-2 font-semibold text-text-main border-b border-border-light whitespace-nowrap bg-slate-100">
66
+ {h}
67
+ </th>
68
+ ))}
69
+ </tr>
70
+ </thead>
71
+ <tbody className="divide-y divide-border-light">
72
+ {body.map((row, i) => (
73
+ <tr key={i} className="hover:bg-slate-50">
74
+ {row.map((cell, j) => (
75
+ <td key={j} className="px-4 py-2 text-text-secondary border-r border-border-light/50 last:border-r-0 whitespace-nowrap" title={cell}>
76
+ {cell}
77
+ </td>
78
+ ))}
79
+ </tr>
80
+ ))}
81
+ </tbody>
82
+ </table>
83
+ </div>
84
+ );
85
+ };
86
+
87
+ const DataImport: React.FC = () => {
88
+ const { currentShop, isLoading: shopLoading } = useShop();
89
+ const [activeTab, setActiveTab] = useState<'etsy' | 'fulfillment'>('etsy');
90
+ const [loading, setLoading] = useState(false);
91
+ const [message, setMessage] = useState<string | null>(null);
92
+
93
+ // Data
94
+ const [providers, setProviders] = useState<FulfillmentProvider[]>([]);
95
+ const [history, setHistory] = useState<ImportHistory[]>([]);
96
+
97
+ // Fulfillment Selection
98
+ const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
99
+
100
+ // Modals
101
+ const [showFileModal, setShowFileModal] = useState(false);
102
+ const [viewingFile, setViewingFile] = useState<{ name: string, content: string } | null>(null);
103
+ const [showProviderModal, setShowProviderModal] = useState(false);
104
+ const [newProviderName, setNewProviderName] = useState("");
105
+
106
+ // Load providers when shop changes
107
+ useEffect(() => {
108
+ if (currentShop) {
109
+ loadProviders();
110
+ } else {
111
+ setProviders([]);
112
+ }
113
+ }, [currentShop]);
114
+
115
+ // Load History when dependencies change
116
+ useEffect(() => {
117
+ if (currentShop) {
118
+ loadHistory();
119
+ } else {
120
+ setHistory([]);
121
+ }
122
+ }, [activeTab, selectedProviderId, currentShop]);
123
+
124
+ const loadProviders = async () => {
125
+ if (!currentShop) return;
126
+ try {
127
+ const data = await fetchProviders(currentShop.id);
128
+ setProviders(data);
129
+ } catch (e) {
130
+ console.error("Failed to load providers", e);
131
+ }
132
+ };
133
+
134
+ const loadHistory = async () => {
135
+ if (!currentShop) return;
136
+ try {
137
+ if (activeTab === 'etsy') {
138
+ const data = await fetchImportHistory(currentShop.id, 'etsy');
139
+ setHistory(data);
140
+ } else if (activeTab === 'fulfillment' && selectedProviderId) {
141
+ const data = await fetchImportHistory(currentShop.id, 'fulfillment', selectedProviderId);
142
+ setHistory(data);
143
+ } else {
144
+ setHistory([]);
145
+ }
146
+ } catch (e) {
147
+ console.error("Failed to load history", e);
148
+ }
149
+ };
150
+
151
+ const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
152
+ const file = e.target.files?.[0];
153
+ if (!file || !currentShop) return;
154
+
155
+ if (activeTab === 'fulfillment' && !selectedProviderId) {
156
+ setMessage("Please select a fulfillment provider first.");
157
+ return;
158
+ }
159
+
160
+ setLoading(true);
161
+ setMessage(null);
162
+ try {
163
+ const res = await uploadFile(currentShop.id, activeTab, file, selectedProviderId || undefined);
164
+ setMessage(`${activeTab === 'etsy' ? 'Etsy' : 'Fulfillment'} file uploaded successfully: ${res.message}`);
165
+ loadHistory();
166
+ } catch (err) {
167
+ setMessage("Upload failed. Please check the backend connection.");
168
+ } finally {
169
+ setLoading(false);
170
+ e.target.value = "";
171
+ }
172
+ };
173
+
174
+ const handleDelete = async (id: number) => {
175
+ if (!currentShop) return;
176
+ if (!confirm("Are you sure you want to delete this import? All associated records will be removed.")) return;
177
+ try {
178
+ await deleteImport(currentShop.id, id);
179
+ loadHistory();
180
+ setMessage("Import record deleted.");
181
+ } catch (e) {
182
+ alert("Failed to delete import");
183
+ }
184
+ }
185
+
186
+ const handleViewFile = async (id: number) => {
187
+ if (!currentShop) return;
188
+ try {
189
+ const data = await fetchImportDetails(currentShop.id, id);
190
+ setViewingFile({ name: data.file_name, content: data.raw_content });
191
+ setShowFileModal(true);
192
+ } catch (e) {
193
+ alert("Failed to load file details");
194
+ }
195
+ }
196
+
197
+ const handleCreateProvider = async (mapping: any) => {
198
+ if (!newProviderName || !currentShop) return;
199
+ try {
200
+ await createProvider(currentShop.id, newProviderName, mapping);
201
+ await loadProviders();
202
+ setNewProviderName("");
203
+ setShowProviderModal(false);
204
+ setMessage("Provider created successfully.");
205
+ } catch (e) {
206
+ alert("Failed to create provider");
207
+ }
208
+ }
209
+
210
+ if (shopLoading) {
211
+ return (
212
+ <div className="flex h-full items-center justify-center min-h-[400px]">
213
+ <div className="flex flex-col items-center gap-2">
214
+ <span className="material-symbols-outlined text-4xl text-primary animate-spin">progress_activity</span>
215
+ <p className="text-text-secondary">Loading...</p>
216
+ </div>
217
+ </div>
218
+ );
219
+ }
220
+
221
+ if (!currentShop) {
222
+ return (
223
+ <div className="flex h-full items-center justify-center min-h-[400px]">
224
+ <div className="flex flex-col items-center gap-4 text-center">
225
+ <div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center">
226
+ <span className="material-symbols-outlined text-3xl text-amber-600">storefront</span>
227
+ </div>
228
+ <h2 className="text-xl font-bold text-text-main">No Shop Selected</h2>
229
+ <p className="text-text-secondary max-w-md">
230
+ Please select a shop from the sidebar to import data.
231
+ </p>
232
+ <a
233
+ href="#/shops"
234
+ className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg font-medium"
235
+ >
236
+ Manage Shops
237
+ </a>
238
+ </div>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ return (
244
+ <div className="max-w-[1200px] mx-auto flex flex-col gap-6">
245
+ <div>
246
+ <div className="flex items-center gap-2 mb-1">
247
+ <span className="px-2 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded-full">
248
+ {currentShop.name}
249
+ </span>
250
+ </div>
251
+ <h2 className="text-3xl font-black text-text-main">Data Import</h2>
252
+ <p className="text-text-secondary mt-2">Manage your data sources: Etsy Orders and Fulfillment Costs.</p>
253
+ </div>
254
+
255
+ {/* Tabs */}
256
+ <div className="flex border-b border-border-light">
257
+ <button
258
+ onClick={() => { setActiveTab('etsy'); setMessage(null); }}
259
+ className={`px-6 py-3 font-medium text-sm transition-colors ${activeTab === 'etsy' ? 'border-b-2 border-primary text-primary' : 'text-text-secondary hover:text-text-main'}`}
260
+ >
261
+ Etsy Orders
262
+ </button>
263
+ <button
264
+ onClick={() => { setActiveTab('fulfillment'); setMessage(null); }}
265
+ className={`px-6 py-3 font-medium text-sm transition-colors ${activeTab === 'fulfillment' ? 'border-b-2 border-primary text-primary' : 'text-text-secondary hover:text-text-main'}`}
266
+ >
267
+ Fulfillment Costs
268
+ </button>
269
+ </div>
270
+
271
+ {message && (
272
+ <div className={`p-4 rounded-lg border ${message.includes('failed') || message.includes('select') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 border-green-200 text-green-700'}`}>
273
+ <p className="font-medium flex items-center gap-2">
274
+ <span className="material-symbols-outlined">{message.includes('failed') || message.includes('select') ? 'error' : 'check_circle'}</span>
275
+ {message}
276
+ </p>
277
+ </div>
278
+ )}
279
+
280
+ {/* Content Range */}
281
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
282
+
283
+ {/* Left Panel: Upload / Management */}
284
+ <div className="md:col-span-1 flex flex-col gap-6">
285
+
286
+ {activeTab === 'fulfillment' && (
287
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
288
+ <h3 className="text-lg font-bold text-text-main mb-4">Select Provider</h3>
289
+ <div className="flex gap-2 mb-4">
290
+ <select
291
+ className="flex-1 h-10 px-3 bg-white border border-border-light rounded-lg text-sm outline-none focus:border-primary"
292
+ value={selectedProviderId || ""}
293
+ onChange={(e) => setSelectedProviderId(Number(e.target.value))}
294
+ >
295
+ <option value="" disabled>Select a provider...</option>
296
+ {providers.map(p => (
297
+ <option key={p.id} value={p.id}>{p.name}</option>
298
+ ))}
299
+ </select>
300
+ <button
301
+ onClick={() => setShowProviderModal(true)}
302
+ className="size-10 flex items-center justify-center bg-slate-100 hover:bg-slate-200 rounded-lg text-text-main transition-colors"
303
+ title="Add New Provider"
304
+ >
305
+ <span className="material-symbols-outlined">add</span>
306
+ </button>
307
+ </div>
308
+ {!selectedProviderId && (
309
+ <p className="text-sm text-text-secondary">Select a provider to manage imports.</p>
310
+ )}
311
+ </div>
312
+ )}
313
+
314
+ {(activeTab === 'etsy' || selectedProviderId) && (
315
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
316
+ <div className={`size-12 rounded-full ${activeTab === 'etsy' ? 'bg-orange-100 text-orange-600' : 'bg-blue-100 text-blue-600'} flex items-center justify-center mb-4`}>
317
+ <span className="material-symbols-outlined text-2xl">cloud_upload</span>
318
+ </div>
319
+ <h3 className="text-lg font-bold text-text-main mb-2">Upload New File</h3>
320
+ <p className="text-sm text-text-secondary mb-4">
321
+ {activeTab === 'etsy' ? 'Upload standard Etsy CSV export.' : 'Upload Cost CSV for this provider.'}
322
+ </p>
323
+
324
+ <label className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-border-light rounded-lg cursor-pointer hover:bg-slate-50 transition-colors ${loading ? 'opacity-50 pointer-events-none' : ''}`}>
325
+ <span className="material-symbols-outlined text-text-secondary text-3xl mb-2">upload_file</span>
326
+ <p className="text-sm text-text-secondary"><span className="font-semibold text-primary">Click to upload</span></p>
327
+ <input type="file" className="hidden" accept=".csv" onChange={handleFileUpload} />
328
+ </label>
329
+ </div>
330
+ )}
331
+ </div>
332
+
333
+ {/* Right Panel: History */}
334
+ <div className="md:col-span-2">
335
+ <div className="bg-white rounded-xl border border-border-light shadow-sm overflow-hidden">
336
+ <div className="px-6 py-4 border-b border-border-light bg-slate-50 flex justify-between items-center">
337
+ <h3 className="text-lg font-bold text-text-main">
338
+ {activeTab === 'etsy' ? 'Etsy Import History' : 'Provider Import History'}
339
+ </h3>
340
+ <button onClick={loadHistory} className="text-text-secondary hover:text-primary">
341
+ <span className="material-symbols-outlined">refresh</span>
342
+ </button>
343
+ </div>
344
+
345
+ {!history.length ? (
346
+ <div className="p-8 text-center text-text-secondary">
347
+ <p>No history found.</p>
348
+ </div>
349
+ ) : (
350
+ <div className="overflow-x-auto">
351
+ <table className="w-full text-left text-sm">
352
+ <thead className="text-xs text-text-secondary uppercase bg-slate-50 border-b border-border-light">
353
+ <tr>
354
+ <th className="px-6 py-3 font-semibold">Date</th>
355
+ <th className="px-6 py-3 font-semibold">Filename</th>
356
+ <th className="px-6 py-3 font-semibold text-center">Records</th>
357
+ <th className="px-6 py-3 font-semibold text-right">Actions</th>
358
+ </tr>
359
+ </thead>
360
+ <tbody className="divide-y divide-border-light">
361
+ {history.map(item => (
362
+ <tr key={item.id} className="hover:bg-slate-50">
363
+ <td className="px-6 py-4 text-text-secondary">
364
+ {new Date(item.date).toLocaleDateString()} {new Date(item.date).toLocaleTimeString()}
365
+ </td>
366
+ <td className="px-6 py-4 font-medium text-text-main">{item.file_name}</td>
367
+ <td className="px-6 py-4 text-center">
368
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
369
+ {item.count}
370
+ </span>
371
+ </td>
372
+ <td className="px-6 py-4 text-right flex justify-end gap-2">
373
+ <button
374
+ onClick={() => handleViewFile(item.id)}
375
+ className="text-blue-600 hover:text-blue-800 text-sm font-medium"
376
+ >
377
+ View
378
+ </button>
379
+ <button
380
+ onClick={() => handleDelete(item.id)}
381
+ className="text-red-600 hover:text-red-800 text-sm font-medium"
382
+ >
383
+ Delete
384
+ </button>
385
+ </td>
386
+ </tr>
387
+ ))}
388
+ </tbody>
389
+ </table>
390
+ </div>
391
+ )}
392
+ </div>
393
+ </div>
394
+ </div>
395
+
396
+ {/* Modal: View File */}
397
+ {showFileModal && viewingFile && (
398
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
399
+ <div className="bg-white rounded-xl shadow-xl w-[95vw] h-[90vh] flex flex-col">
400
+ <div className="px-6 py-4 border-b border-border-light flex justify-between items-center">
401
+ <h3 className="text-lg font-bold text-text-main">File Content: {viewingFile.name}</h3>
402
+ <button onClick={() => setShowFileModal(false)} className="text-text-secondary hover:text-text-main">
403
+ <span className="material-symbols-outlined">close</span>
404
+ </button>
405
+ </div>
406
+ <div className="flex-1 overflow-hidden bg-white">
407
+ <CSVPreview content={viewingFile.content} />
408
+ </div>
409
+ </div>
410
+ </div>
411
+ )}
412
+
413
+ {/* Modal: Add Provider */}
414
+ {showProviderModal && (
415
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
416
+ <div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
417
+ <h3 className="text-xl font-bold text-text-main mb-4">Add Fulfillment Provider</h3>
418
+
419
+ <div className="space-y-4">
420
+ <div>
421
+ <label className="block text-sm font-medium text-text-secondary mb-1">Provider Name</label>
422
+ <input
423
+ type="text"
424
+ className="w-full h-10 px-3 bg-white border border-border-light rounded-lg text-sm outline-none focus:border-primary"
425
+ placeholder="e.g. Printify"
426
+ value={newProviderName}
427
+ onChange={(e) => setNewProviderName(e.target.value)}
428
+ />
429
+ </div>
430
+
431
+ <div className="border-t border-border-light pt-4">
432
+ <h4 className="text-sm font-bold text-text-main mb-2">Column Mapping</h4>
433
+ <p className="text-xs text-text-secondary mb-3">Enter the exact column headers from your CSV file.</p>
434
+
435
+ <div className="grid grid-cols-2 gap-4">
436
+ <div className="col-span-2">
437
+ <label className="block text-xs font-medium text-text-secondary mb-1">Ref/Order ID Column</label>
438
+ <input type="text" id="ref_id_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="e.g. Reference ID" />
439
+ </div>
440
+
441
+ <div>
442
+ <label className="block text-xs font-medium text-text-secondary mb-1">First Name Column</label>
443
+ <input type="text" id="first_name_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="First Name" />
444
+ </div>
445
+ <div>
446
+ <label className="block text-xs font-medium text-text-secondary mb-1">Last Name Column</label>
447
+ <input type="text" id="last_name_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="Last Name" />
448
+ </div>
449
+
450
+ <div className="col-span-2">
451
+ <label className="block text-xs font-medium text-text-secondary mb-1">Address Column (Street 1)</label>
452
+ <input type="text" id="address_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="Address Line 1" />
453
+ </div>
454
+
455
+ <div>
456
+ <label className="block text-xs font-medium text-text-secondary mb-1">City Column</label>
457
+ <input type="text" id="city_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="City" />
458
+ </div>
459
+ <div>
460
+ <label className="block text-xs font-medium text-text-secondary mb-1">State Column</label>
461
+ <input type="text" id="state_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="State" />
462
+ </div>
463
+
464
+ <div className="col-span-2">
465
+ <label className="block text-xs font-medium text-text-secondary mb-1">Total Cost Column (Important)</label>
466
+ <input type="text" id="total_cost_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="e.g. Total Cost" />
467
+ </div>
468
+
469
+ <div className="col-span-2">
470
+ <label className="block text-xs font-medium text-text-secondary mb-1">Tracking Number (Optional)</label>
471
+ <input type="text" id="tracking_col" className="w-full h-9 px-3 bg-slate-50 border border-border-light rounded text-sm outline-none focus:border-primary" placeholder="e.g. Tracking" />
472
+ </div>
473
+ </div>
474
+ </div>
475
+ </div>
476
+
477
+ <div className="flex justify-end gap-2 mt-6">
478
+ <button
479
+ onClick={() => setShowProviderModal(false)}
480
+ className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-slate-100 rounded-lg"
481
+ >
482
+ Cancel
483
+ </button>
484
+ <button
485
+ onClick={() => {
486
+ const mapping: any = {};
487
+ ['ref_id_col', 'first_name_col', 'last_name_col', 'address_col', 'city_col', 'state_col', 'total_cost_col', 'tracking_col'].forEach(id => {
488
+ const val = (document.getElementById(id) as HTMLInputElement)?.value;
489
+ if (val) mapping[id] = val;
490
+ });
491
+
492
+ if (!newProviderName) {
493
+ alert("Please enter a provider name");
494
+ return;
495
+ }
496
+
497
+ handleCreateProvider(mapping);
498
+ }}
499
+ className="px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary-dark rounded-lg"
500
+ >
501
+ Create
502
+ </button>
503
+ </div>
504
+ </div>
505
+ </div>
506
+ )}
507
+ </div>
508
+ );
509
+ };
510
+
511
+ export default DataImport;
frontend/pages/Login.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useAuth } from '../contexts/AuthContext';
3
+ import { useNavigate, useLocation } from 'react-router-dom';
4
+ import { API_BASE_URL } from '../services/api';
5
+
6
+ export const Login: React.FC = () => {
7
+ const [username, setUsername] = useState('');
8
+ const [password, setPassword] = useState('');
9
+ const [error, setError] = useState('');
10
+ const [isSubmitting, setIsSubmitting] = useState(false);
11
+
12
+ const { login } = useAuth();
13
+ const navigate = useNavigate();
14
+ const location = useLocation();
15
+
16
+ // Redirect to where they came from or dashboard
17
+ const from = location.state?.from?.pathname || "/dashboard";
18
+
19
+ const handleSubmit = async (e: React.FormEvent) => {
20
+ e.preventDefault();
21
+ setError('');
22
+ setIsSubmitting(true);
23
+
24
+ try {
25
+ const formData = new FormData();
26
+ formData.append('username', username);
27
+ formData.append('password', password);
28
+
29
+ const response = await fetch(`${API_BASE_URL}/token`, {
30
+ method: 'POST',
31
+ body: formData,
32
+ });
33
+
34
+ if (!response.ok) {
35
+ const data = await response.json();
36
+ throw new Error(data.detail || 'Login failed');
37
+ }
38
+
39
+ const data = await response.json();
40
+ await login(data.access_token);
41
+ navigate(from, { replace: true });
42
+ } catch (err: any) {
43
+ setError(err.message || 'An error occurred during login');
44
+ } finally {
45
+ setIsSubmitting(false);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
51
+ <div className="max-w-md w-full space-y-8">
52
+ <div>
53
+ <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
54
+ Sign in to your account
55
+ </h2>
56
+ </div>
57
+ <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
58
+ <div className="rounded-md shadow-sm -space-y-px">
59
+ <div>
60
+ <label htmlFor="username" className="sr-only">Username</label>
61
+ <input
62
+ id="username"
63
+ name="username"
64
+ type="text"
65
+ required
66
+ className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
67
+ placeholder="Username"
68
+ value={username}
69
+ onChange={(e) => setUsername(e.target.value)}
70
+ />
71
+ </div>
72
+ <div>
73
+ <label htmlFor="password" className="sr-only">Password</label>
74
+ <input
75
+ id="password"
76
+ name="password"
77
+ type="password"
78
+ required
79
+ className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
80
+ placeholder="Password"
81
+ value={password}
82
+ onChange={(e) => setPassword(e.target.value)}
83
+ />
84
+ </div>
85
+ </div>
86
+
87
+ {error && (
88
+ <div className="text-red-600 text-sm text-center">
89
+ {error}
90
+ </div>
91
+ )}
92
+
93
+ <div>
94
+ <button
95
+ type="submit"
96
+ disabled={isSubmitting}
97
+ className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
98
+ >
99
+ {isSubmitting ? 'Signing in...' : 'Sign in'}
100
+ </button>
101
+ </div>
102
+ </form>
103
+ </div>
104
+ </div>
105
+ );
106
+ };
frontend/pages/Orders.tsx ADDED
@@ -0,0 +1,664 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useMemo } from 'react';
2
+ import { fetchOrders, fetchAllShopsOrders } from '../services/api';
3
+ import { Order, AllShopsOrder } from '../types';
4
+ import { useShop } from '../contexts/ShopContext';
5
+
6
+ // Color palette for shop badges
7
+ const SHOP_COLORS: Record<number, string> = {};
8
+ const COLOR_PALETTE = ['#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#f97316', '#eab308'];
9
+ let colorIndex = 0;
10
+
11
+ const getShopColor = (shopId: number): string => {
12
+ if (!SHOP_COLORS[shopId]) {
13
+ SHOP_COLORS[shopId] = COLOR_PALETTE[colorIndex % COLOR_PALETTE.length];
14
+ colorIndex++;
15
+ }
16
+ return SHOP_COLORS[shopId];
17
+ };
18
+
19
+ // Time period presets - reuse consistent definitions
20
+ const MAIN_PRESETS = [
21
+ { label: 'Today', value: 'today' },
22
+ { label: 'Yesterday', value: 'yesterday' },
23
+ { label: 'Last 7 days', value: '7d' },
24
+ { label: 'Last 30 days', value: '30d' },
25
+ { label: 'All Time', value: 'all' },
26
+ ];
27
+
28
+ const QUICK_SELECT_OPTIONS = [
29
+ { label: 'Last 90 days', value: '90d' },
30
+ { label: 'This Year', value: 'ytd' },
31
+ { label: 'Last Year', value: 'lastyear' },
32
+ ];
33
+
34
+ const getDateRangeFromPreset = (preset: string): { startDate: string; endDate: string } | null => {
35
+ const today = new Date();
36
+ const formatDate = (d: Date) => d.toISOString().split('T')[0];
37
+
38
+ switch (preset) {
39
+ case 'today':
40
+ return { startDate: formatDate(today), endDate: formatDate(today) };
41
+ case 'yesterday': {
42
+ const yesterday = new Date(today);
43
+ yesterday.setDate(yesterday.getDate() - 1);
44
+ return { startDate: formatDate(yesterday), endDate: formatDate(yesterday) };
45
+ }
46
+ case '7d': {
47
+ const start = new Date(today);
48
+ start.setDate(start.getDate() - 6);
49
+ return { startDate: formatDate(start), endDate: formatDate(today) };
50
+ }
51
+ case '30d': {
52
+ const start = new Date(today);
53
+ start.setDate(start.getDate() - 29);
54
+ return { startDate: formatDate(start), endDate: formatDate(today) };
55
+ }
56
+ case '90d': {
57
+ const start = new Date(today);
58
+ start.setDate(start.getDate() - 89);
59
+ return { startDate: formatDate(start), endDate: formatDate(today) };
60
+ }
61
+ case 'ytd': {
62
+ const start = new Date(today.getFullYear(), 0, 1);
63
+ return { startDate: formatDate(start), endDate: formatDate(today) };
64
+ }
65
+ case 'lastyear': {
66
+ const start = new Date(today.getFullYear() - 1, 0, 1);
67
+ const end = new Date(today.getFullYear() - 1, 11, 31);
68
+ return { startDate: formatDate(start), endDate: formatDate(end) };
69
+ }
70
+ case 'all':
71
+ default:
72
+ return null;
73
+ }
74
+ };
75
+
76
+ const Orders: React.FC = () => {
77
+ const { currentShop, isLoading: shopLoading, isAllShopsMode, shops } = useShop();
78
+ const [orders, setOrders] = useState<Order[]>([]);
79
+ const [allShopsOrders, setAllShopsOrders] = useState<AllShopsOrder[]>([]);
80
+ const [loading, setLoading] = useState(true);
81
+ const [searchTerm, setSearchTerm] = useState('');
82
+ const [selectedShopFilter, setSelectedShopFilter] = useState<number | 'all'>('all');
83
+
84
+ // Time selector state
85
+ const [selectedPreset, setSelectedPreset] = useState('all'); // Default to all time for orders
86
+ const [customStartDate, setCustomStartDate] = useState('');
87
+ const [customEndDate, setCustomEndDate] = useState('');
88
+ const [showCustomPicker, setShowCustomPicker] = useState(false);
89
+
90
+ // Pagination state
91
+ const [currentPage, setCurrentPage] = useState(1);
92
+ const ordersPerPage = 20;
93
+
94
+ // Time Selector Handlers
95
+ const handlePresetChange = (preset: string) => {
96
+ setSelectedPreset(preset);
97
+ if (preset !== 'custom') {
98
+ setShowCustomPicker(false);
99
+ }
100
+ };
101
+
102
+ const handleQuickSelect = (value: string) => {
103
+ setSelectedPreset(value);
104
+ setShowCustomPicker(false);
105
+ };
106
+
107
+ const applyCustomDates = () => {
108
+ if (customStartDate && customEndDate) {
109
+ setSelectedPreset('custom');
110
+ setShowCustomPicker(false);
111
+ }
112
+ };
113
+
114
+ useEffect(() => {
115
+ const loadOrders = async () => {
116
+ setLoading(true);
117
+ try {
118
+ if (isAllShopsMode) {
119
+ const data = await fetchAllShopsOrders();
120
+ setAllShopsOrders(data);
121
+ setOrders([]);
122
+ } else if (currentShop) {
123
+ const data = await fetchOrders(currentShop.id);
124
+ setOrders(data);
125
+ setAllShopsOrders([]);
126
+ } else {
127
+ setOrders([]);
128
+ setAllShopsOrders([]);
129
+ }
130
+ } catch (e) {
131
+ console.error("Failed to load orders", e);
132
+ } finally {
133
+ setLoading(false);
134
+ }
135
+ };
136
+ loadOrders();
137
+ }, [currentShop, isAllShopsMode]);
138
+
139
+ // Reset pagination when filters change
140
+ useEffect(() => {
141
+ setCurrentPage(1);
142
+ }, [searchTerm, selectedShopFilter, isAllShopsMode, selectedPreset, customStartDate, customEndDate]);
143
+
144
+ // Date Filtering Helper
145
+ const filterByDate = (items: any[]) => {
146
+ let startDate: Date | null = null;
147
+ let endDate: Date | null = null;
148
+
149
+ if (selectedPreset === 'custom' && customStartDate && customEndDate) {
150
+ startDate = new Date(customStartDate);
151
+ endDate = new Date(customEndDate);
152
+ } else if (selectedPreset !== 'all') {
153
+ const range = getDateRangeFromPreset(selectedPreset);
154
+ if (range) {
155
+ startDate = new Date(range.startDate);
156
+ endDate = new Date(range.endDate);
157
+ }
158
+ }
159
+
160
+ if (!startDate || !endDate) return items;
161
+
162
+ // End date should be end of day
163
+ const adjustEndDate = new Date(endDate);
164
+ adjustEndDate.setHours(23, 59, 59, 999);
165
+
166
+ return items.filter(item => {
167
+ // Assuming item.date is YYYY-MM-DD or similar format
168
+ if (!item.date) return false;
169
+ const itemDate = new Date(item.date);
170
+ return itemDate >= startDate! && itemDate <= adjustEndDate;
171
+ });
172
+ };
173
+
174
+ // Filter logic for single shop mode
175
+ const filteredOrders = useMemo(() => {
176
+ let filtered = orders.filter(order =>
177
+ order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
178
+ order.customer?.toLowerCase().includes(searchTerm.toLowerCase())
179
+ );
180
+ return filterByDate(filtered);
181
+ }, [orders, searchTerm, selectedPreset, customStartDate, customEndDate]);
182
+
183
+ // Paginated single shop orders
184
+ const paginatedOrders = useMemo(() => {
185
+ const startIndex = (currentPage - 1) * ordersPerPage;
186
+ return filteredOrders.slice(startIndex, startIndex + ordersPerPage);
187
+ }, [filteredOrders, currentPage]);
188
+
189
+ const totalPagesOrders = Math.ceil(filteredOrders.length / ordersPerPage);
190
+
191
+ // Filter logic for all shops mode
192
+ const filteredAllShopsOrders = useMemo(() => {
193
+ let filtered = allShopsOrders.filter(order => {
194
+ const matchesSearch = order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
195
+ order.customer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
196
+ order.shop_name?.toLowerCase().includes(searchTerm.toLowerCase());
197
+ const matchesShop = selectedShopFilter === 'all' || order.shop_id === selectedShopFilter;
198
+ return matchesSearch && matchesShop;
199
+ });
200
+ return filterByDate(filtered);
201
+ }, [allShopsOrders, searchTerm, selectedShopFilter, selectedPreset, customStartDate, customEndDate]);
202
+
203
+ // Paginated all shops orders
204
+ const paginatedAllShopsOrders = useMemo(() => {
205
+ const startIndex = (currentPage - 1) * ordersPerPage;
206
+ return filteredAllShopsOrders.slice(startIndex, startIndex + ordersPerPage);
207
+ }, [filteredAllShopsOrders, currentPage]);
208
+
209
+ const totalPagesAllShops = Math.ceil(filteredAllShopsOrders.length / ordersPerPage);
210
+
211
+ // Statistics for all shops mode
212
+ const allShopsStats = useMemo(() => {
213
+ const uniqueShops = new Set(filteredAllShopsOrders.map(o => o.shop_id));
214
+ const totalValue = filteredAllShopsOrders.reduce((sum, o) => sum + (o.total || 0), 0);
215
+ const shippedCount = filteredAllShopsOrders.filter(o => o.status === 'Shipped').length;
216
+ return {
217
+ orderCount: filteredAllShopsOrders.length,
218
+ shopCount: uniqueShops.size,
219
+ totalValue,
220
+ shippedCount
221
+ };
222
+ }, [filteredAllShopsOrders]);
223
+
224
+ const formatCurrency = (val: number) => `$${val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
225
+
226
+ // Reusable TimeSelector Component
227
+ const TimeSelector = () => (
228
+ <div className="flex flex-col gap-2">
229
+ <div className="flex items-center gap-2 flex-wrap">
230
+ {MAIN_PRESETS.map((preset) => (
231
+ <button
232
+ key={preset.value}
233
+ onClick={() => handlePresetChange(preset.value)}
234
+ className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-all ${selectedPreset === preset.value
235
+ ? 'bg-primary text-white shadow-md'
236
+ : 'bg-white border border-border-light text-text-main hover:bg-slate-50'
237
+ }`}
238
+ >
239
+ {preset.label}
240
+ </button>
241
+ ))}
242
+ <div className="relative">
243
+ <button
244
+ onClick={() => setShowCustomPicker(!showCustomPicker)}
245
+ className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-all flex items-center gap-1 ${selectedPreset === 'custom' || QUICK_SELECT_OPTIONS.some(o => o.value === selectedPreset)
246
+ ? 'bg-primary text-white shadow-md'
247
+ : 'bg-white border border-border-light text-text-main hover:bg-slate-50'
248
+ }`}
249
+ >
250
+ <span className="material-symbols-outlined text-[16px]">calendar_month</span>
251
+ {selectedPreset === 'custom' ? 'Custom' :
252
+ QUICK_SELECT_OPTIONS.find(o => o.value === selectedPreset)?.label || 'More'}
253
+ </button>
254
+
255
+ {/* Custom Picker Dropdown */}
256
+ {showCustomPicker && (
257
+ <div className="absolute right-0 top-full mt-2 bg-white border border-border-light rounded-xl shadow-lg p-4 z-50 min-w-[300px]">
258
+ <p className="text-xs font-semibold text-text-secondary uppercase mb-2">Quick Select</p>
259
+ <div className="flex flex-wrap gap-2 mb-4">
260
+ {QUICK_SELECT_OPTIONS.map((opt) => (
261
+ <button
262
+ key={opt.value}
263
+ onClick={() => handleQuickSelect(opt.value)}
264
+ className="px-3 py-1.5 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
265
+ >
266
+ {opt.label}
267
+ </button>
268
+ ))}
269
+ </div>
270
+ <p className="text-xs font-semibold text-text-secondary uppercase mb-2">Custom Range</p>
271
+ <div className="flex items-center gap-2 mb-3">
272
+ <input
273
+ type="date"
274
+ value={customStartDate}
275
+ onChange={(e) => setCustomStartDate(e.target.value)}
276
+ className="flex-1 px-2 py-1.5 text-sm border border-border-light rounded-lg focus:outline-none focus:border-primary"
277
+ />
278
+ <span className="text-text-secondary text-sm">to</span>
279
+ <input
280
+ type="date"
281
+ value={customEndDate}
282
+ onChange={(e) => setCustomEndDate(e.target.value)}
283
+ className="flex-1 px-2 py-1.5 text-sm border border-border-light rounded-lg focus:outline-none focus:border-primary"
284
+ />
285
+ </div>
286
+ <button
287
+ onClick={applyCustomDates}
288
+ disabled={!customStartDate || !customEndDate}
289
+ className="w-full py-2 bg-primary text-white text-sm font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary/90"
290
+ >
291
+ Apply
292
+ </button>
293
+ </div>
294
+ )}
295
+ </div>
296
+ </div>
297
+ </div>
298
+ );
299
+
300
+ // Pagination Component
301
+ const PaginationComponent = ({ currentPage, totalPages, onPageChange }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void }) => {
302
+ if (totalPages <= 1) return null;
303
+
304
+ return (
305
+ <div className="flex items-center justify-between px-6 py-4 border-t border-border-light bg-slate-50">
306
+ <p className="text-sm text-text-secondary">
307
+ Page {currentPage} of {totalPages}
308
+ </p>
309
+ <div className="flex items-center gap-2">
310
+ <button
311
+ onClick={() => onPageChange(Math.max(1, currentPage - 1))}
312
+ disabled={currentPage === 1}
313
+ className="px-3 py-1.5 text-sm font-medium rounded-lg border border-border-light bg-white hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
314
+ >
315
+ <span className="material-symbols-outlined text-[16px]">chevron_left</span>
316
+ Previous
317
+ </button>
318
+ <div className="flex items-center gap-1">
319
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
320
+ let pageNum;
321
+ if (totalPages <= 5) {
322
+ pageNum = i + 1;
323
+ } else if (currentPage <= 3) {
324
+ pageNum = i + 1;
325
+ } else if (currentPage >= totalPages - 2) {
326
+ pageNum = totalPages - 4 + i;
327
+ } else {
328
+ pageNum = currentPage - 2 + i;
329
+ }
330
+ return (
331
+ <button
332
+ key={pageNum}
333
+ onClick={() => onPageChange(pageNum)}
334
+ className={`w-8 h-8 text-sm font-medium rounded-lg ${currentPage === pageNum
335
+ ? 'bg-primary text-white'
336
+ : 'bg-white border border-border-light hover:bg-slate-50'
337
+ }`}
338
+ >
339
+ {pageNum}
340
+ </button>
341
+ );
342
+ })}
343
+ </div>
344
+ <button
345
+ onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
346
+ disabled={currentPage === totalPages}
347
+ className="px-3 py-1.5 text-sm font-medium rounded-lg border border-border-light bg-white hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
348
+ >
349
+ Next
350
+ <span className="material-symbols-outlined text-[16px]">chevron_right</span>
351
+ </button>
352
+ </div>
353
+ </div>
354
+ );
355
+ };
356
+
357
+ if (shopLoading || loading) {
358
+ return (
359
+ <div className="flex h-full items-center justify-center min-h-[400px]">
360
+ <div className="flex flex-col items-center gap-2">
361
+ <span className="material-symbols-outlined text-4xl text-primary animate-spin">progress_activity</span>
362
+ <p className="text-text-secondary">Loading orders...</p>
363
+ </div>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ if (!currentShop && !isAllShopsMode) {
369
+ return (
370
+ <div className="flex h-full items-center justify-center min-h-[400px]">
371
+ <div className="flex flex-col items-center gap-4 text-center">
372
+ <div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center">
373
+ <span className="material-symbols-outlined text-3xl text-amber-600">storefront</span>
374
+ </div>
375
+ <h2 className="text-xl font-bold text-text-main">No Shop Selected</h2>
376
+ <p className="text-text-secondary max-w-md">
377
+ Please select a shop from the sidebar to view orders.
378
+ </p>
379
+ <a
380
+ href="#/shops"
381
+ className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg font-medium"
382
+ >
383
+ Manage Shops
384
+ </a>
385
+ </div>
386
+ </div>
387
+ );
388
+ }
389
+
390
+ // ALL SHOPS MODE VIEW
391
+ if (isAllShopsMode) {
392
+ return (
393
+ <div className="max-w-[1400px] mx-auto flex flex-col gap-6">
394
+ {/* Header with Time Selector */}
395
+ <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
396
+ <div>
397
+ <div className="flex items-center gap-2 mb-1">
398
+ <span className="px-3 py-1 text-xs font-semibold bg-gradient-to-r from-purple-100 to-indigo-100 text-purple-700 rounded-full border border-purple-200">
399
+ 📊 All Shops
400
+ </span>
401
+ </div>
402
+ <h1 className="text-text-main text-3xl font-extrabold leading-tight tracking-tight">All Orders</h1>
403
+ <p className="text-text-secondary text-base font-normal">View and manage orders from all shops</p>
404
+ </div>
405
+ <TimeSelector />
406
+ </div>
407
+
408
+ {/* Stats Bar */}
409
+ <div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl p-4 border border-purple-200 flex flex-wrap items-center gap-6">
410
+ <div className="flex items-center gap-2">
411
+ <span className="material-symbols-outlined text-purple-600">receipt_long</span>
412
+ <span className="text-sm font-medium text-purple-800">{allShopsStats.orderCount} orders</span>
413
+ </div>
414
+ <div className="flex items-center gap-2">
415
+ <span className="material-symbols-outlined text-purple-600">storefront</span>
416
+ <span className="text-sm font-medium text-purple-800">{allShopsStats.shopCount} shops</span>
417
+ </div>
418
+ <div className="flex items-center gap-2">
419
+ <span className="material-symbols-outlined text-purple-600">payments</span>
420
+ <span className="text-sm font-medium text-purple-800">{formatCurrency(allShopsStats.totalValue)} total</span>
421
+ </div>
422
+ <div className="flex items-center gap-2">
423
+ <span className="material-symbols-outlined text-purple-600">local_shipping</span>
424
+ <span className="text-sm font-medium text-purple-800">{allShopsStats.shippedCount} shipped</span>
425
+ </div>
426
+ </div>
427
+
428
+ {/* Filters */}
429
+ <div className="flex flex-wrap items-center gap-4 bg-white p-4 rounded-xl border border-border-light shadow-sm">
430
+ <div className="flex-1 min-w-[300px] relative">
431
+ <span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary">search</span>
432
+ <input
433
+ className="w-full h-10 pl-10 pr-4 bg-white border border-border-light text-text-main rounded-lg focus:ring-1 focus:ring-primary focus:border-primary placeholder-text-secondary text-sm"
434
+ placeholder="Search by order ID, customer, or shop..."
435
+ type="text"
436
+ value={searchTerm}
437
+ onChange={(e) => setSearchTerm(e.target.value)}
438
+ />
439
+ </div>
440
+ <div className="flex gap-3">
441
+ <select
442
+ value={selectedShopFilter}
443
+ onChange={(e) => setSelectedShopFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
444
+ className="h-10 px-4 bg-white border border-border-light rounded-lg text-text-main text-sm font-medium shadow-sm focus:outline-none focus:border-primary"
445
+ >
446
+ <option value="all">All Shops</option>
447
+ {shops.map(shop => (
448
+ <option key={shop.id} value={shop.id}>{shop.name}</option>
449
+ ))}
450
+ </select>
451
+ </div>
452
+ </div>
453
+
454
+ {/* Table */}
455
+ <div className="flex flex-col border border-border-light rounded-xl bg-white overflow-hidden shadow-sm">
456
+ {filteredAllShopsOrders.length === 0 ? (
457
+ <div className="p-12 text-center text-text-secondary">
458
+ <span className="material-symbols-outlined text-4xl mb-2">receipt_long</span>
459
+ <p>{allShopsOrders.length === 0 ? 'No orders yet. Import Etsy orders to get started.' : 'No matching orders found.'}</p>
460
+ </div>
461
+ ) : (
462
+ <>
463
+ <div className="overflow-x-auto">
464
+ <table className="w-full text-left border-collapse">
465
+ <thead className="bg-slate-50 border-b border-border-light">
466
+ <tr>
467
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Shop</th>
468
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Order ID</th>
469
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Date</th>
470
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Customer</th>
471
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Products</th>
472
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase text-right">Total</th>
473
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase text-right">Fulfill Cost</th>
474
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Status</th>
475
+ </tr>
476
+ </thead>
477
+ <tbody className="divide-y divide-border-light">
478
+ {paginatedAllShopsOrders.map(order => (
479
+ <tr key={`${order.shop_id}-${order.id}`} className="group hover:bg-slate-50 transition-colors">
480
+ <td className="p-4">
481
+ <span
482
+ className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium text-white"
483
+ style={{ backgroundColor: getShopColor(order.shop_id) }}
484
+ >
485
+ {order.shop_name}
486
+ </span>
487
+ </td>
488
+ <td className="p-4"><span className="text-primary font-semibold cursor-pointer hover:underline">{order.id}</span></td>
489
+ <td className="p-4 text-sm text-text-main">{order.date}</td>
490
+ <td className="p-4 text-sm font-medium text-text-main">{order.customer}</td>
491
+ <td className="p-4 text-sm text-text-secondary">{order.items}</td>
492
+ <td className="p-4 text-right text-sm font-bold text-text-main">{formatCurrency(order.total)}</td>
493
+ <td className="p-4 text-right text-sm">
494
+ {order.fulfillment_cost ? (
495
+ <div className="flex flex-col items-end">
496
+ <span className="font-medium text-text-main">{formatCurrency(order.fulfillment_cost)}</span>
497
+ {order.match_info && order.match_info.length > 0 && (
498
+ <div className="flex gap-1 mt-1 justify-end flex-wrap max-w-[120px]">
499
+ {order.match_info.map((m, i) => (
500
+ <span key={i} className="text-[10px] bg-green-100 text-green-700 px-1 rounded truncate max-w-full" title={m}>
501
+ {m.replace('Verified: ', '')}
502
+ </span>
503
+ ))}
504
+ </div>
505
+ )}
506
+ </div>
507
+ ) : (
508
+ <span className="text-text-secondary text-xs italic">--</span>
509
+ )}
510
+ </td>
511
+ <td className="p-4">
512
+ <StatusBadge status={order.status} />
513
+ </td>
514
+ </tr>
515
+ ))}
516
+ </tbody>
517
+ </table>
518
+ </div>
519
+
520
+ {/* Pagination */}
521
+ <PaginationComponent
522
+ currentPage={currentPage}
523
+ totalPages={totalPagesAllShops}
524
+ onPageChange={setCurrentPage}
525
+ />
526
+ </>
527
+ )}
528
+ </div>
529
+ </div>
530
+ );
531
+ }
532
+
533
+ // SINGLE SHOP MODE VIEW
534
+ return (
535
+ <div className="max-w-[1400px] mx-auto flex flex-col gap-6">
536
+ {/* Header with Time Selector */}
537
+ <div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
538
+ <div className="flex flex-col gap-1">
539
+ <div className="flex items-center gap-2 mb-1">
540
+ <span className="px-2 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded-full">
541
+ {currentShop?.name}
542
+ </span>
543
+ </div>
544
+ <h1 className="text-text-main text-3xl font-extrabold leading-tight tracking-tight">Order List</h1>
545
+ <p className="text-text-secondary text-base font-normal">Manage and track all order statuses</p>
546
+ </div>
547
+ <TimeSelector />
548
+ </div>
549
+
550
+ {/* Filters */}
551
+ <div className="flex flex-wrap items-center gap-4 bg-white p-4 rounded-xl border border-border-light shadow-sm">
552
+ <div className="flex-1 min-w-[300px] relative">
553
+ <span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary">search</span>
554
+ <input
555
+ className="w-full h-10 pl-10 pr-4 bg-white border border-border-light text-text-main rounded-lg focus:ring-1 focus:ring-primary focus:border-primary placeholder-text-secondary text-sm"
556
+ placeholder="Search by order ID, customer..."
557
+ type="text"
558
+ value={searchTerm}
559
+ onChange={(e) => setSearchTerm(e.target.value)}
560
+ />
561
+ </div>
562
+ <div className="flex gap-3">
563
+ <button className="h-10 px-4 bg-white border border-border-light rounded-lg text-text-main text-sm font-medium hover:bg-slate-50 flex items-center gap-2">
564
+ <span className="material-symbols-outlined text-[20px]">filter_list</span>
565
+ Filter
566
+ </button>
567
+ </div>
568
+ </div>
569
+
570
+ {/* Table */}
571
+ <div className="flex flex-col border border-border-light rounded-xl bg-white overflow-hidden shadow-sm">
572
+ {filteredOrders.length === 0 ? (
573
+ <div className="p-12 text-center text-text-secondary">
574
+ <span className="material-symbols-outlined text-4xl mb-2">receipt_long</span>
575
+ <p>{orders.length === 0 ? 'No orders yet. Import Etsy orders to get started.' : 'No matching orders found.'}</p>
576
+ </div>
577
+ ) : (
578
+ <>
579
+ <div className="overflow-x-auto">
580
+ <table className="w-full text-left border-collapse">
581
+ <thead className="bg-slate-50 border-b border-border-light">
582
+ <tr>
583
+ <th className="p-4 w-12"><input type="checkbox" className="rounded border-gray-300 text-primary focus:ring-primary" /></th>
584
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Order ID</th>
585
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Date</th>
586
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Customer</th>
587
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Products</th>
588
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase text-right">Total</th>
589
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase text-right">Fulfill Cost</th>
590
+ <th className="p-4 text-xs font-semibold text-text-secondary uppercase">Status</th>
591
+ <th className="p-4 w-12"></th>
592
+ </tr>
593
+ </thead>
594
+ <tbody className="divide-y divide-border-light">
595
+ {paginatedOrders.map(order => (
596
+ <tr key={order.id} className="group hover:bg-slate-50 transition-colors">
597
+ <td className="p-4"><input type="checkbox" className="rounded border-gray-300 text-primary focus:ring-primary" /></td>
598
+ <td className="p-4"><span className="text-primary font-semibold cursor-pointer hover:underline">{order.id}</span></td>
599
+ <td className="p-4 text-sm text-text-main">{order.date}</td>
600
+ <td className="p-4 text-sm font-medium text-text-main">{order.customer}</td>
601
+ <td className="p-4 text-sm text-text-secondary">{order.items}</td>
602
+ <td className="p-4 text-right text-sm font-bold text-text-main">{formatCurrency(order.total)}</td>
603
+ <td className="p-4 text-right text-sm">
604
+ {order.fulfillment_cost ? (
605
+ <div className="flex flex-col items-end">
606
+ <span className="font-medium text-text-main">{formatCurrency(order.fulfillment_cost)}</span>
607
+ {order.match_info && order.match_info.length > 0 && (
608
+ <div className="flex gap-1 mt-1 justify-end flex-wrap max-w-[120px]">
609
+ {order.match_info.map((m, i) => (
610
+ <span key={i} className="text-[10px] bg-green-100 text-green-700 px-1 rounded truncate max-w-full" title={m}>
611
+ {m.replace('Verified: ', '')}
612
+ </span>
613
+ ))}
614
+ </div>
615
+ )}
616
+ </div>
617
+ ) : (
618
+ <span className="text-text-secondary text-xs italic">--</span>
619
+ )}
620
+ </td>
621
+ <td className="p-4">
622
+ <StatusBadge status={order.status} />
623
+ </td>
624
+ <td className="p-4">
625
+ <button className="text-text-secondary hover:text-primary transition-colors">
626
+ <span className="material-symbols-outlined">more_vert</span>
627
+ </button>
628
+ </td>
629
+ </tr>
630
+ ))}
631
+ </tbody>
632
+ </table>
633
+ </div>
634
+
635
+ {/* Pagination */}
636
+ <PaginationComponent
637
+ currentPage={currentPage}
638
+ totalPages={totalPagesOrders}
639
+ onPageChange={setCurrentPage}
640
+ />
641
+ </>
642
+ )}
643
+ </div>
644
+ </div>
645
+ );
646
+ };
647
+
648
+ const StatusBadge = ({ status }: { status: string }) => {
649
+ const getStatusStyle = (s: string) => {
650
+ switch (s) {
651
+ case 'Shipped': return 'bg-green-100 text-green-800';
652
+ case 'Unshipped': return 'bg-yellow-100 text-yellow-800';
653
+ default: return 'bg-slate-100 text-slate-800';
654
+ }
655
+ };
656
+
657
+ return (
658
+ <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusStyle(status)}`}>
659
+ {status}
660
+ </span>
661
+ );
662
+ };
663
+
664
+ export default Orders;
frontend/pages/ProfitAnalytics.tsx ADDED
@@ -0,0 +1,938 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useMemo } from 'react';
2
+ import { fetchProfitData, fetchAllShopsProfitData, fetchAllShopsSummary, fetchShopSummary } from '../services/api';
3
+ import { ProfitData, AllShopsProfitData, AllShopsSummaryResponse, ShopSummaryResponse } from '../types';
4
+ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Legend, LineChart, Line } from 'recharts';
5
+ import { useShop } from '../contexts/ShopContext';
6
+
7
+ // Color palette for shops
8
+ const SHOP_COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#f97316', '#eab308'];
9
+
10
+ const getShopColor = (index: number): string => SHOP_COLORS[index % SHOP_COLORS.length];
11
+
12
+ // Time period presets - Main buttons (same as Dashboard)
13
+ const MAIN_PRESETS = [
14
+ { label: 'Today', value: 'today' },
15
+ { label: 'Yesterday', value: 'yesterday' },
16
+ { label: 'Last 7 days', value: '7d' },
17
+ { label: 'Last 30 days', value: '30d' },
18
+ { label: 'All Time', value: 'all' },
19
+ ];
20
+
21
+ // Quick select options in custom picker
22
+ const QUICK_SELECT_OPTIONS = [
23
+ { label: 'Last 90 days', value: '90d' },
24
+ { label: 'This Year', value: 'ytd' },
25
+ { label: 'Last Year', value: 'lastyear' },
26
+ ];
27
+
28
+ // Helper to get date range from preset
29
+ const getDateRangeFromPreset = (preset: string): { startDate: string; endDate: string } | null => {
30
+ const today = new Date();
31
+ const formatDate = (d: Date) => d.toISOString().split('T')[0];
32
+
33
+ switch (preset) {
34
+ case 'today':
35
+ return { startDate: formatDate(today), endDate: formatDate(today) };
36
+ case 'yesterday': {
37
+ const yesterday = new Date(today);
38
+ yesterday.setDate(yesterday.getDate() - 1);
39
+ return { startDate: formatDate(yesterday), endDate: formatDate(yesterday) };
40
+ }
41
+ case '7d': {
42
+ const start = new Date(today);
43
+ start.setDate(start.getDate() - 6);
44
+ return { startDate: formatDate(start), endDate: formatDate(today) };
45
+ }
46
+ case '30d': {
47
+ const start = new Date(today);
48
+ start.setDate(start.getDate() - 29);
49
+ return { startDate: formatDate(start), endDate: formatDate(today) };
50
+ }
51
+ case '90d': {
52
+ const start = new Date(today);
53
+ start.setDate(start.getDate() - 89);
54
+ return { startDate: formatDate(start), endDate: formatDate(today) };
55
+ }
56
+ case 'ytd': {
57
+ const start = new Date(today.getFullYear(), 0, 1);
58
+ return { startDate: formatDate(start), endDate: formatDate(today) };
59
+ }
60
+ case 'lastyear': {
61
+ const start = new Date(today.getFullYear() - 1, 0, 1);
62
+ const end = new Date(today.getFullYear() - 1, 11, 31);
63
+ return { startDate: formatDate(start), endDate: formatDate(end) };
64
+ }
65
+ case 'all':
66
+ default:
67
+ return null;
68
+ }
69
+ };
70
+
71
+ // Get display days for trend chart
72
+ const getTrendDays = (preset: string): number => {
73
+ switch (preset) {
74
+ case 'today': return 1;
75
+ case 'yesterday': return 1;
76
+ case '7d': return 7;
77
+ case '30d': return 30;
78
+ case '90d': return 90;
79
+ case 'ytd': return Math.ceil((new Date().getTime() - new Date(new Date().getFullYear(), 0, 1).getTime()) / (1000 * 60 * 60 * 24));
80
+ case 'lastyear': return 365;
81
+ case 'all':
82
+ default: return 30; // Default to 30 days for all time
83
+ }
84
+ };
85
+
86
+ const ProfitAnalytics: React.FC = () => {
87
+ const { currentShop, isLoading: shopLoading, isAllShopsMode, shops } = useShop();
88
+ const [data, setData] = useState<ProfitData[]>([]);
89
+ const [allShopsData, setAllShopsData] = useState<AllShopsProfitData[]>([]);
90
+ const [allShopsSummary, setAllShopsSummary] = useState<AllShopsSummaryResponse | null>(null);
91
+ const [singleShopSummary, setSingleShopSummary] = useState<ShopSummaryResponse | null>(null);
92
+ const [loading, setLoading] = useState(true);
93
+ const [selectedShopFilter, setSelectedShopFilter] = useState<number | 'all'>('all');
94
+
95
+ // Time period state
96
+ const [selectedPreset, setSelectedPreset] = useState('30d');
97
+ const [customStartDate, setCustomStartDate] = useState('');
98
+ const [customEndDate, setCustomEndDate] = useState('');
99
+ const [showCustomPicker, setShowCustomPicker] = useState(false);
100
+
101
+ // Pagination & Search state
102
+ const [ordersSearchTerm, setOrdersSearchTerm] = useState('');
103
+ const [ordersPage, setOrdersPage] = useState(1);
104
+ const ordersPerPage = 15;
105
+
106
+ // Load data when preset changes
107
+ useEffect(() => {
108
+ const loadData = async () => {
109
+ setLoading(true);
110
+ try {
111
+ let startDate: string | undefined;
112
+ let endDate: string | undefined;
113
+
114
+ if (selectedPreset === 'custom' && customStartDate && customEndDate) {
115
+ startDate = customStartDate;
116
+ endDate = customEndDate;
117
+ } else if (selectedPreset !== 'all') {
118
+ const range = getDateRangeFromPreset(selectedPreset);
119
+ if (range) {
120
+ startDate = range.startDate;
121
+ endDate = range.endDate;
122
+ }
123
+ }
124
+
125
+ if (isAllShopsMode) {
126
+ const [profitData, summaryData] = await Promise.all([
127
+ fetchAllShopsProfitData(),
128
+ fetchAllShopsSummary(startDate, endDate)
129
+ ]);
130
+ setAllShopsData(profitData);
131
+ setAllShopsSummary(summaryData);
132
+ setData([]);
133
+ setSingleShopSummary(null);
134
+ } else if (currentShop) {
135
+ const [profitResult, summaryResult] = await Promise.all([
136
+ fetchProfitData(currentShop.id),
137
+ fetchShopSummary(currentShop.id, startDate, endDate)
138
+ ]);
139
+ setData(profitResult);
140
+ setSingleShopSummary(summaryResult);
141
+ setAllShopsData([]);
142
+ setAllShopsSummary(null);
143
+ } else {
144
+ setData([]);
145
+ setAllShopsData([]);
146
+ setAllShopsSummary(null);
147
+ setSingleShopSummary(null);
148
+ }
149
+ } catch (e) {
150
+ console.error("Failed to load profit data", e);
151
+ } finally {
152
+ setLoading(false);
153
+ }
154
+ };
155
+ loadData();
156
+ }, [currentShop, isAllShopsMode, selectedPreset, customStartDate, customEndDate]);
157
+
158
+ // Reset pagination when filter changes
159
+ useEffect(() => {
160
+ setOrdersPage(1);
161
+ }, [selectedShopFilter, ordersSearchTerm, currentShop]);
162
+
163
+ const formatCurrency = (val: number) => `$${val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
164
+
165
+ // Get margin class without emoji
166
+ const getMarginClass = (margin: number) => {
167
+ if (margin >= 30) return 'text-green-600 bg-green-50';
168
+ if (margin >= 15) return 'text-yellow-600 bg-yellow-50';
169
+ return 'text-red-600 bg-red-50';
170
+ };
171
+
172
+ const handlePresetChange = (preset: string) => {
173
+ setSelectedPreset(preset);
174
+ if (preset !== 'custom') {
175
+ setShowCustomPicker(false);
176
+ }
177
+ };
178
+
179
+ const handleQuickSelect = (value: string) => {
180
+ setSelectedPreset(value);
181
+ setShowCustomPicker(false);
182
+ };
183
+
184
+ const applyCustomDates = () => {
185
+ if (customStartDate && customEndDate) {
186
+ setSelectedPreset('custom');
187
+ setShowCustomPicker(false);
188
+ }
189
+ };
190
+
191
+ // Filter logic for single shop mode
192
+ const filteredSingleShopData = useMemo(() => {
193
+ let items = data;
194
+
195
+ // Filter by date first
196
+ let startDate: Date | null = null;
197
+ let endDate: Date | null = null;
198
+
199
+ if (selectedPreset === 'custom' && customStartDate && customEndDate) {
200
+ startDate = new Date(customStartDate);
201
+ endDate = new Date(customEndDate);
202
+ } else if (selectedPreset !== 'all') {
203
+ const range = getDateRangeFromPreset(selectedPreset);
204
+ if (range) {
205
+ startDate = new Date(range.startDate);
206
+ endDate = new Date(range.endDate);
207
+ }
208
+ }
209
+
210
+ if (startDate && endDate) {
211
+ items = items.filter(order => {
212
+ if (!order.sale_date) return false;
213
+ const orderDate = new Date(order.sale_date);
214
+ return orderDate >= startDate! && orderDate <= endDate!;
215
+ });
216
+ }
217
+
218
+ // Then filter by search term
219
+ if (ordersSearchTerm) {
220
+ const term = ordersSearchTerm.toLowerCase();
221
+ items = items.filter(order =>
222
+ order.order_id.toLowerCase().includes(term) ||
223
+ order.customer?.toLowerCase().includes(term)
224
+ );
225
+ }
226
+
227
+ return items;
228
+ }, [data, selectedPreset, customStartDate, customEndDate, ordersSearchTerm]);
229
+
230
+ // Filter logic for all shops mode
231
+ const filteredAllShopsData = useMemo(() => {
232
+ let items = allShopsData;
233
+
234
+ // Date filter
235
+ let startDate: Date | null = null;
236
+ let endDate: Date | null = null;
237
+
238
+ if (selectedPreset === 'custom' && customStartDate && customEndDate) {
239
+ startDate = new Date(customStartDate);
240
+ endDate = new Date(customEndDate);
241
+ } else if (selectedPreset !== 'all') {
242
+ const range = getDateRangeFromPreset(selectedPreset);
243
+ if (range) {
244
+ startDate = new Date(range.startDate);
245
+ endDate = new Date(range.endDate);
246
+ }
247
+ }
248
+
249
+ if (startDate && endDate) {
250
+ items = items.filter(order => {
251
+ if (!order.sale_date) return false;
252
+ const orderDate = new Date(order.sale_date);
253
+ return orderDate >= startDate! && orderDate <= endDate!;
254
+ });
255
+ }
256
+
257
+ // Shop and Search filter
258
+ return items.filter(order => {
259
+ const matchesShop = selectedShopFilter === 'all' || order.shop_id === selectedShopFilter;
260
+ const matchesSearch = ordersSearchTerm === '' ||
261
+ order.order_id.toLowerCase().includes(ordersSearchTerm.toLowerCase()) ||
262
+ order.customer?.toLowerCase().includes(ordersSearchTerm.toLowerCase()) ||
263
+ order.shop_name?.toLowerCase().includes(ordersSearchTerm.toLowerCase());
264
+ return matchesShop && matchesSearch;
265
+ });
266
+ }, [allShopsData, selectedPreset, customStartDate, customEndDate, selectedShopFilter, ordersSearchTerm]);
267
+
268
+ // Helpers to access paginated data depending on mode
269
+ const currentTableData = isAllShopsMode ? filteredAllShopsData : filteredSingleShopData;
270
+ const totalPages = Math.ceil(currentTableData.length / ordersPerPage);
271
+ const paginatedData = useMemo(() => {
272
+ const startIndex = (ordersPage - 1) * ordersPerPage;
273
+ return currentTableData.slice(startIndex, startIndex + ordersPerPage);
274
+ }, [currentTableData, ordersPage]);
275
+
276
+ // Single shop chart data
277
+ const singleShopChartData = useMemo(() => {
278
+ const grouped = filteredSingleShopData.reduce((acc, curr) => {
279
+ const date = curr.sale_date?.substring(5) || 'N/A';
280
+ if (!acc[date]) acc[date] = { name: date, revenue: 0, profit: 0, cost: 0 };
281
+ acc[date].revenue += curr.revenue;
282
+ acc[date].profit += curr.profit;
283
+ acc[date].cost += curr.cost;
284
+ return acc;
285
+ }, {} as Record<string, any>);
286
+
287
+ // Show max 30 points or all filtered points
288
+ const keys = Object.keys(grouped).sort();
289
+ return keys.map(k => grouped[k]);
290
+ }, [filteredSingleShopData]);
291
+
292
+ // Define TimeSelector Component here or reuse
293
+ const TimeSelector = () => (
294
+ <div className="flex flex-col gap-2">
295
+ <div className="flex items-center gap-2 flex-wrap">
296
+ {MAIN_PRESETS.map((preset) => (
297
+ <button
298
+ key={preset.value}
299
+ onClick={() => handlePresetChange(preset.value)}
300
+ className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-all ${selectedPreset === preset.value
301
+ ? 'bg-primary text-white shadow-md'
302
+ : 'bg-white border border-border-light text-text-main hover:bg-slate-50'
303
+ }`}
304
+ >
305
+ {preset.label}
306
+ </button>
307
+ ))}
308
+ <div className="relative">
309
+ <button
310
+ onClick={() => setShowCustomPicker(!showCustomPicker)}
311
+ className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-all flex items-center gap-1 ${selectedPreset === 'custom' || QUICK_SELECT_OPTIONS.some(o => o.value === selectedPreset)
312
+ ? 'bg-primary text-white shadow-md'
313
+ : 'bg-white border border-border-light text-text-main hover:bg-slate-50'
314
+ }`}
315
+ >
316
+ <span className="material-symbols-outlined text-[16px]">calendar_month</span>
317
+ {selectedPreset === 'custom' ? 'Custom' :
318
+ QUICK_SELECT_OPTIONS.find(o => o.value === selectedPreset)?.label || 'More'}
319
+ </button>
320
+
321
+ {/* Custom Picker Dropdown */}
322
+ {showCustomPicker && (
323
+ <div className="absolute right-0 top-full mt-2 bg-white border border-border-light rounded-xl shadow-lg p-4 z-50 min-w-[300px]">
324
+ <p className="text-xs font-semibold text-text-secondary uppercase mb-2">Quick Select</p>
325
+ <div className="flex flex-wrap gap-2 mb-4">
326
+ {QUICK_SELECT_OPTIONS.map((opt) => (
327
+ <button
328
+ key={opt.value}
329
+ onClick={() => handleQuickSelect(opt.value)}
330
+ className="px-3 py-1.5 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
331
+ >
332
+ {opt.label}
333
+ </button>
334
+ ))}
335
+ </div>
336
+ <p className="text-xs font-semibold text-text-secondary uppercase mb-2">Custom Range</p>
337
+ <div className="flex items-center gap-2 mb-3">
338
+ <input
339
+ type="date"
340
+ value={customStartDate}
341
+ onChange={(e) => setCustomStartDate(e.target.value)}
342
+ className="flex-1 px-2 py-1.5 text-sm border border-border-light rounded-lg focus:outline-none focus:border-primary"
343
+ />
344
+ <span className="text-text-secondary text-sm">to</span>
345
+ <input
346
+ type="date"
347
+ value={customEndDate}
348
+ onChange={(e) => setCustomEndDate(e.target.value)}
349
+ className="flex-1 px-2 py-1.5 text-sm border border-border-light rounded-lg focus:outline-none focus:border-primary"
350
+ />
351
+ </div>
352
+ <button
353
+ onClick={applyCustomDates}
354
+ disabled={!customStartDate || !customEndDate}
355
+ className="w-full py-2 bg-primary text-white text-sm font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-primary/90"
356
+ >
357
+ Apply
358
+ </button>
359
+ </div>
360
+ )}
361
+ </div>
362
+ </div>
363
+ </div>
364
+ );
365
+
366
+ if (shopLoading || loading) {
367
+ return (
368
+ <div className="flex h-full items-center justify-center">
369
+ <div className="flex flex-col items-center gap-2">
370
+ <span className="material-symbols-outlined text-4xl text-primary animate-spin">progress_activity</span>
371
+ <p className="text-text-secondary">Loading financial data...</p>
372
+ </div>
373
+ </div>
374
+ );
375
+ }
376
+
377
+ if (!currentShop && !isAllShopsMode) {
378
+ return (
379
+ <div className="flex h-full items-center justify-center min-h-[400px]">
380
+ <div className="flex flex-col items-center gap-4 text-center">
381
+ <div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center">
382
+ <span className="material-symbols-outlined text-3xl text-amber-600">storefront</span>
383
+ </div>
384
+ <h2 className="text-xl font-bold text-text-main">No Shop Selected</h2>
385
+ <p className="text-text-secondary max-w-md">
386
+ Please select a shop from the sidebar to view profit analytics.
387
+ </p>
388
+ <a
389
+ href="#/shops"
390
+ className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg font-medium"
391
+ >
392
+ Manage Shops
393
+ </a>
394
+ </div>
395
+ </div>
396
+ );
397
+ }
398
+
399
+ // ALL SHOPS MODE VIEW
400
+ if (isAllShopsMode && allShopsSummary) {
401
+ const { aggregates: agg, shops: shopList, changes } = allShopsSummary;
402
+
403
+ // Shop profit comparison data
404
+ const profitComparisonData = shopList.map((shop, idx) => ({
405
+ name: shop.shop_name.length > 15 ? shop.shop_name.substring(0, 15) + '...' : shop.shop_name,
406
+ profit: shop.total_profit,
407
+ revenue: shop.total_revenue,
408
+ cost: shop.total_cost,
409
+ margin: shop.profit_margin,
410
+ fill: getShopColor(idx)
411
+ }));
412
+
413
+ // Unique shop names for multi-line chart
414
+ // Need to refilter profit data based on date for chart
415
+ const itemsForChart = allShopsData.filter(order => {
416
+ if (!order.sale_date) return false;
417
+ let startDate: Date | null = null;
418
+ let endDate: Date | null = null;
419
+
420
+ if (selectedPreset === 'custom' && customStartDate && customEndDate) {
421
+ startDate = new Date(customStartDate);
422
+ endDate = new Date(customEndDate);
423
+ } else if (selectedPreset !== 'all') {
424
+ const range = getDateRangeFromPreset(selectedPreset);
425
+ if (range) {
426
+ startDate = new Date(range.startDate);
427
+ endDate = new Date(range.endDate);
428
+ }
429
+ }
430
+ if (!startDate || !endDate) return true;
431
+ const orderDate = new Date(order.sale_date);
432
+ return orderDate >= startDate && orderDate <= endDate;
433
+ });
434
+
435
+ const multiLineTrendData = (() => {
436
+ const dateGroups: Record<string, Record<string, number>> = {};
437
+ itemsForChart.forEach(order => {
438
+ const date = order.sale_date?.toString().substring(5) || 'N/A';
439
+ if (!dateGroups[date]) {
440
+ dateGroups[date] = { name: date };
441
+ }
442
+ const shopKey = order.shop_name.replace(/\s+/g, '_');
443
+ dateGroups[date][shopKey] = (dateGroups[date][shopKey] || 0) + order.profit;
444
+ });
445
+ const sortedDates = Object.keys(dateGroups).sort();
446
+ const maxPoints = Math.min(sortedDates.length, 30);
447
+ return sortedDates.slice(-maxPoints).map(date => dateGroups[date]);
448
+ })();
449
+
450
+ const shopNames = [...new Set(itemsForChart.map(o => o.shop_name))];
451
+
452
+ return (
453
+ <div className="max-w-[1400px] mx-auto flex flex-col gap-6">
454
+ {/* Header */}
455
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
456
+ <div>
457
+ <div className="flex items-center gap-2 mb-1">
458
+ <span className="px-3 py-1 text-xs font-semibold bg-gradient-to-r from-purple-100 to-indigo-100 text-purple-700 rounded-full border border-purple-200">
459
+ 📊 All Shops
460
+ </span>
461
+ </div>
462
+ <h2 className="text-3xl font-black tracking-tight mb-1 text-text-main">Multi-Shop Profit Analytics</h2>
463
+ <p className="text-text-secondary">Compare revenue, costs, and profit across all your shops.</p>
464
+ </div>
465
+
466
+ {/* Time Period Selector */}
467
+ <TimeSelector />
468
+ </div>
469
+
470
+ {/* Aggregated KPI Cards with Real Change Indicator */}
471
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
472
+ <KpiCard title="Total Revenue" value={formatCurrency(agg.total_revenue)} icon="payments" color="primary" subtitle={`${agg.shop_count} shops`} changePercent={changes?.revenue_change} />
473
+ <KpiCard title="Total Costs" value={formatCurrency(agg.total_cost)} icon="shopping_bag" color="orange" subtitle={`Fulfillment costs`} changePercent={changes?.cost_change} />
474
+ <KpiCard title="Total Profit" value={formatCurrency(agg.total_profit)} icon="savings" color="emerald" subtitle={`${agg.overall_margin}% margin`} changePercent={changes?.profit_change} />
475
+ <KpiCard title="Orders Processed" value={agg.total_orders.toString()} icon="receipt_long" color="blue" subtitle={`${agg.total_fulfilled} fulfilled`} changePercent={changes?.orders_change} />
476
+ </div>
477
+
478
+ {/* Profit Trend by Shop */}
479
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
480
+ <h3 className="text-lg font-bold text-text-main mb-2">Profit Trend by Shop</h3>
481
+ <div className="h-[300px]">
482
+ {multiLineTrendData.length > 0 ? (
483
+ <ResponsiveContainer width="100%" height="100%">
484
+ <LineChart data={multiLineTrendData}>
485
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
486
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
487
+ <YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
488
+ <Tooltip />
489
+ <Legend />
490
+ {shopNames.map((name, idx) => (
491
+ <Line
492
+ key={name}
493
+ type="monotone"
494
+ dataKey={name.replace(/\s+/g, '_')}
495
+ stroke={getShopColor(idx)}
496
+ strokeWidth={2}
497
+ dot={false}
498
+ name={name}
499
+ />
500
+ ))}
501
+ </LineChart>
502
+ </ResponsiveContainer>
503
+ ) : (
504
+ <div className="flex items-center justify-center h-full text-text-secondary">
505
+ <p>No trend data available for selected period</p>
506
+ </div>
507
+ )}
508
+ </div>
509
+ </div>
510
+
511
+ {/* Shop Margin Indicators */}
512
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
513
+ <h3 className="text-lg font-bold text-text-main mb-4">Shop Profit Margins</h3>
514
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
515
+ {shopList.map((shop, idx) => {
516
+ const marginClass = getMarginClass(shop.profit_margin);
517
+ return (
518
+ <div key={shop.shop_id} className="flex items-center gap-3 p-3 rounded-lg bg-slate-50 border border-border-light">
519
+ <div
520
+ className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm"
521
+ style={{ backgroundColor: getShopColor(idx) }}
522
+ >
523
+ {shop.shop_name.charAt(0).toUpperCase()}
524
+ </div>
525
+ <div className="flex-1 min-w-0">
526
+ <p className="text-sm font-medium text-text-main truncate">{shop.shop_name}</p>
527
+ <p className="text-xs text-text-secondary">{formatCurrency(shop.total_profit)}</p>
528
+ </div>
529
+ <div className={`px-2.5 py-1 rounded-lg text-sm font-bold ${marginClass}`}>
530
+ {shop.profit_margin}%
531
+ </div>
532
+ </div>
533
+ );
534
+ })}
535
+ </div>
536
+ </div>
537
+
538
+ {/* Charts Row */}
539
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
540
+ {/* Profit Comparison Bar Chart */}
541
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm min-h-[400px]">
542
+ <h3 className="text-lg font-bold text-text-main mb-6">Shop Profit Ranking</h3>
543
+ <div className="h-[320px]">
544
+ {profitComparisonData.length > 0 ? (
545
+ <ResponsiveContainer width="100%" height="100%">
546
+ <BarChart data={profitComparisonData} layout="vertical" margin={{ left: 20 }}>
547
+ <CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#e2e8f0" />
548
+ <XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} tickFormatter={(val) => `$${(val / 1000).toFixed(0)}k`} />
549
+ <YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} width={120} />
550
+ <Tooltip formatter={(val: number) => formatCurrency(val)} />
551
+ <Bar dataKey="profit" radius={[0, 4, 4, 0]} name="Profit" />
552
+ </BarChart>
553
+ </ResponsiveContainer>
554
+ ) : (
555
+ <div className="flex items-center justify-center h-full text-text-secondary">
556
+ <p>No data available</p>
557
+ </div>
558
+ )}
559
+ </div>
560
+ </div>
561
+
562
+ {/* Revenue vs Cost Comparison */}
563
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm min-h-[400px]">
564
+ <h3 className="text-lg font-bold text-text-main mb-6">Revenue vs Cost by Shop</h3>
565
+ <div className="h-[320px]">
566
+ {profitComparisonData.length > 0 ? (
567
+ <ResponsiveContainer width="100%" height="100%">
568
+ <BarChart data={profitComparisonData}>
569
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
570
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 10 }} angle={-45} textAnchor="end" height={80} />
571
+ <YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} tickFormatter={(val) => `$${(val / 1000).toFixed(0)}k`} />
572
+ <Tooltip formatter={(val: number) => formatCurrency(val)} />
573
+ <Legend />
574
+ <Bar dataKey="revenue" fill="#6366f1" radius={[4, 4, 0, 0]} name="Revenue" />
575
+ <Bar dataKey="cost" fill="#f97316" radius={[4, 4, 0, 0]} name="Cost" />
576
+ </BarChart>
577
+ </ResponsiveContainer>
578
+ ) : (
579
+ <div className="flex items-center justify-center h-full text-text-secondary">
580
+ <p>No data available</p>
581
+ </div>
582
+ )}
583
+ </div>
584
+ </div>
585
+ </div>
586
+
587
+ {/* Detail Table */}
588
+ <DetailTable
589
+ data={paginatedData}
590
+ totalOrders={currentTableData.length}
591
+ page={ordersPage}
592
+ setPage={setOrdersPage}
593
+ totalPages={totalPages}
594
+ searchTerm={ordersSearchTerm}
595
+ setSearchTerm={setOrdersSearchTerm}
596
+ shops={shops}
597
+ selectedShopFilter={selectedShopFilter}
598
+ setSelectedShopFilter={setSelectedShopFilter}
599
+ isAllShops={true}
600
+ formatCurrency={formatCurrency}
601
+ getMarginClass={getMarginClass}
602
+ getShopColor={getShopColor}
603
+ ordersPerPage={ordersPerPage}
604
+ />
605
+ </div>
606
+ );
607
+ }
608
+
609
+ // SINGLE SHOP MODE VIEW
610
+ // Ensure we have data
611
+ const aggregates = singleShopSummary?.aggregates || {
612
+ total_revenue: 0,
613
+ total_cost: 0,
614
+ total_profit: 0,
615
+ total_orders: 0,
616
+ fulfillment_rate: 0,
617
+ profit_margin: 0,
618
+ total_fulfilled: 0
619
+ };
620
+ const shopChanges = singleShopSummary?.changes;
621
+
622
+ return (
623
+ <div className="max-w-[1400px] mx-auto flex flex-col gap-6">
624
+ {/* Header */}
625
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
626
+ <div>
627
+ <div className="flex items-center gap-2 mb-1">
628
+ <span className="px-2 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded-full">
629
+ {currentShop?.name}
630
+ </span>
631
+ </div>
632
+ <h2 className="text-3xl font-black tracking-tight mb-1 text-text-main">Revenue & Profit Analytics</h2>
633
+ <p className="text-text-secondary">Track your shop's net profit after fulfillment costs.</p>
634
+ </div>
635
+ {/* Time Period Selector */}
636
+ <div className="flex items-center gap-3">
637
+ <TimeSelector />
638
+ <button className="flex items-center justify-center gap-2 h-10 px-5 bg-primary text-white rounded-lg text-sm font-bold shadow-md hover:bg-primary-dark transition-colors">
639
+ <span className="material-symbols-outlined text-[20px]">download</span>
640
+ <span>Export</span>
641
+ </button>
642
+ </div>
643
+ </div>
644
+
645
+ {/* KPI Cards */}
646
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
647
+ <KpiCard title="Total Revenue" value={formatCurrency(aggregates.total_revenue)} icon="payments" color="primary" changePercent={shopChanges?.revenue_change} />
648
+ <KpiCard title="Total Costs" value={formatCurrency(aggregates.total_cost)} icon="shopping_bag" color="orange" changePercent={shopChanges?.cost_change} />
649
+ <KpiCard title="Net Profit" value={formatCurrency(aggregates.total_profit)} icon="savings" color="emerald" changePercent={shopChanges?.profit_change} />
650
+ <KpiCard title="Profit Margin" value={`${aggregates.profit_margin}%`} icon="percent" color="blue" changePercent={shopChanges?.profit_change} />
651
+ </div>
652
+
653
+ {/* Charts Row */}
654
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
655
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm min-h-[400px]">
656
+ <h3 className="text-lg font-bold text-text-main mb-6">Profit Trend</h3>
657
+ <div className="h-[300px]">
658
+ {singleShopChartData.length > 0 ? (
659
+ <ResponsiveContainer width="100%" height="100%">
660
+ <AreaChart data={singleShopChartData}>
661
+ <defs>
662
+ <linearGradient id="colorProfit" x1="0" y1="0" x2="0" y2="1">
663
+ <stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
664
+ <stop offset="95%" stopColor="#10b981" stopOpacity={0} />
665
+ </linearGradient>
666
+ </defs>
667
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
668
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} dy={10} />
669
+ <YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
670
+ <Tooltip contentStyle={{ borderRadius: '8px' }} />
671
+ <Area type="monotone" dataKey="profit" stroke="#10b981" strokeWidth={3} fillOpacity={1} fill="url(#colorProfit)" />
672
+ </AreaChart>
673
+ </ResponsiveContainer>
674
+ ) : (
675
+ <div className="flex items-center justify-center h-full text-text-secondary">
676
+ <p>No data available</p>
677
+ </div>
678
+ )}
679
+ </div>
680
+ </div>
681
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm min-h-[400px]">
682
+ <h3 className="text-lg font-bold text-text-main mb-6">Revenue vs Cost</h3>
683
+ <div className="h-[300px]">
684
+ {singleShopChartData.length > 0 ? (
685
+ <ResponsiveContainer width="100%" height="100%">
686
+ <BarChart data={singleShopChartData}>
687
+ <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
688
+ <XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} dy={10} />
689
+ <YAxis axisLine={false} tickLine={false} tick={{ fill: '#64748b', fontSize: 12 }} />
690
+ <Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ borderRadius: '8px' }} />
691
+ <Legend />
692
+ <Bar dataKey="revenue" fill="#137fec" radius={[4, 4, 0, 0]} name="Revenue" />
693
+ <Bar dataKey="cost" fill="#f97316" radius={[4, 4, 0, 0]} name="Cost" />
694
+ </BarChart>
695
+ </ResponsiveContainer>
696
+ ) : (
697
+ <div className="flex items-center justify-center h-full text-text-secondary">
698
+ <p>No data available</p>
699
+ </div>
700
+ )}
701
+ </div>
702
+ </div>
703
+ </div>
704
+
705
+ {/* Detail Table */}
706
+ <DetailTable
707
+ data={paginatedData}
708
+ totalOrders={currentTableData.length}
709
+ page={ordersPage}
710
+ setPage={setOrdersPage}
711
+ totalPages={totalPages}
712
+ searchTerm={ordersSearchTerm}
713
+ setSearchTerm={setOrdersSearchTerm}
714
+ shops={shops} // Not needed for single shop filter but consistent API
715
+ selectedShopFilter={null}
716
+ setSelectedShopFilter={null}
717
+ isAllShops={false}
718
+ formatCurrency={formatCurrency}
719
+ getMarginClass={getMarginClass}
720
+ getShopColor={getShopColor}
721
+ ordersPerPage={ordersPerPage}
722
+ />
723
+ </div>
724
+ );
725
+ };
726
+
727
+ interface KpiCardProps {
728
+ title: string;
729
+ value: string;
730
+ icon: string;
731
+ color?: string;
732
+ subtitle?: string;
733
+ changePercent?: number | null;
734
+ }
735
+
736
+ const KpiCard = ({ title, value, icon, color = "primary", subtitle, changePercent }: KpiCardProps) => {
737
+ const colorMap: any = {
738
+ primary: "text-primary bg-blue-50",
739
+ orange: "text-orange-600 bg-orange-50",
740
+ emerald: "text-emerald-600 bg-emerald-50",
741
+ blue: "text-blue-600 bg-blue-50"
742
+ };
743
+
744
+ return (
745
+ <div className="bg-white rounded-xl p-5 border border-border-light shadow-sm flex flex-col gap-1">
746
+ <div className="flex items-center justify-between">
747
+ <p className="text-text-secondary text-sm font-medium">{title}</p>
748
+ <div className={`p-1.5 rounded-md ${colorMap[color]}`}>
749
+ <span className="material-symbols-outlined text-[20px] block">{icon}</span>
750
+ </div>
751
+ </div>
752
+ <p className="text-2xl font-bold tracking-tight text-text-main mt-1">{value}</p>
753
+ {subtitle && <p className="text-xs text-text-secondary">{subtitle}</p>}
754
+ {changePercent !== undefined && changePercent !== null && (
755
+ <div className={`flex items-center gap-1 mt-1 text-xs font-medium ${changePercent >= 0 ? 'text-green-600' : 'text-red-500'}`}>
756
+ <span className="material-symbols-outlined text-[14px]">
757
+ {changePercent >= 0 ? 'trending_up' : 'trending_down'}
758
+ </span>
759
+ <span>{changePercent >= 0 ? '+' : ''}{changePercent.toFixed(1)}% vs previous period</span>
760
+ </div>
761
+ )}
762
+ </div>
763
+ )
764
+ }
765
+
766
+ const DetailTable = ({
767
+ data,
768
+ totalOrders,
769
+ page,
770
+ setPage,
771
+ totalPages,
772
+ searchTerm,
773
+ setSearchTerm,
774
+ shops,
775
+ selectedShopFilter,
776
+ setSelectedShopFilter,
777
+ isAllShops,
778
+ formatCurrency,
779
+ getMarginClass,
780
+ getShopColor,
781
+ ordersPerPage
782
+ }: any) => {
783
+ return (
784
+ <div className="bg-white rounded-xl border border-border-light shadow-sm overflow-hidden">
785
+ <div className="p-6 border-b border-border-light flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50">
786
+ <div>
787
+ <h3 className="text-lg font-bold text-text-main">Order Financial Details</h3>
788
+ <p className="text-sm text-text-secondary">Showing {totalOrders} orders</p>
789
+ </div>
790
+ <div className="flex flex-wrap items-center gap-3">
791
+ {/* Search Input */}
792
+ <div className="relative">
793
+ <span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary text-[18px]">search</span>
794
+ <input
795
+ type="text"
796
+ placeholder="Search orders..."
797
+ value={searchTerm}
798
+ onChange={(e) => setSearchTerm(e.target.value)}
799
+ className="h-10 pl-9 pr-4 bg-white border border-border-light rounded-lg text-text-main text-sm focus:outline-none focus:border-primary w-48"
800
+ />
801
+ </div>
802
+ {isAllShops && (
803
+ <select
804
+ value={selectedShopFilter}
805
+ onChange={(e) => setSelectedShopFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
806
+ className="h-10 px-4 bg-white border border-border-light rounded-lg text-text-main text-sm font-medium shadow-sm focus:outline-none focus:border-primary"
807
+ >
808
+ <option value="all">All Shops</option>
809
+ {shops.map((shop: any) => (
810
+ <option key={shop.id} value={shop.id}>{shop.name}</option>
811
+ ))}
812
+ </select>
813
+ )}
814
+ </div>
815
+ </div>
816
+ {data.length === 0 ? (
817
+ <div className="p-12 text-center text-text-secondary">
818
+ <span className="material-symbols-outlined text-4xl mb-2">receipt_long</span>
819
+ <p>No order data available for selected period.</p>
820
+ </div>
821
+ ) : (
822
+ <>
823
+ <div className="overflow-x-auto">
824
+ <table className="w-full text-sm text-left">
825
+ <thead className="text-xs text-text-secondary uppercase bg-slate-50 border-b border-border-light">
826
+ <tr>
827
+ {isAllShops && <th className="px-6 py-4 font-semibold">Shop</th>}
828
+ <th className="px-6 py-4 font-semibold">Order ID</th>
829
+ <th className="px-6 py-4 font-semibold">Date</th>
830
+ <th className="px-6 py-4 font-semibold">Customer</th>
831
+ <th className="px-6 py-4 font-semibold text-right">Revenue</th>
832
+ <th className="px-6 py-4 font-semibold text-right">Cost</th>
833
+ <th className="px-6 py-4 font-semibold text-right">Profit</th>
834
+ <th className="px-6 py-4 font-semibold text-center">Margin</th>
835
+ <th className="px-6 py-4 font-semibold text-center">Status</th>
836
+ </tr>
837
+ </thead>
838
+ <tbody className="divide-y divide-border-light">
839
+ {data.map((row: any) => {
840
+ const shopIdx = isAllShops ? shops.findIndex((s: any) => s.id === row.shop_id) : 0;
841
+ const marginClass = getMarginClass(row.margin);
842
+ return (
843
+ <tr key={`${row.shop_id || 'single'}-${row.order_id}`} className="hover:bg-slate-50 transition-colors">
844
+ {isAllShops && (
845
+ <td className="px-6 py-4">
846
+ <span
847
+ className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium text-white"
848
+ style={{ backgroundColor: getShopColor(shopIdx >= 0 ? shopIdx : 0) }}
849
+ >
850
+ {row.shop_name}
851
+ </span>
852
+ </td>
853
+ )}
854
+ <td className="px-6 py-4 font-medium text-primary">{row.order_id}</td>
855
+ <td className="px-6 py-4 text-text-secondary">{row.sale_date}</td>
856
+ <td className="px-6 py-4 font-medium text-text-main">{row.customer}</td>
857
+ <td className="px-6 py-4 text-right font-medium">{formatCurrency(row.revenue)}</td>
858
+ <td className="px-6 py-4 text-right text-text-secondary">{formatCurrency(row.cost)}</td>
859
+ <td className={`px-6 py-4 text-right font-bold ${row.profit > 0 ? 'text-green-600' : 'text-red-500'}`}>
860
+ {formatCurrency(row.profit)}
861
+ </td>
862
+ <td className="px-6 py-4 text-center">
863
+ <span className={`inline-flex items-center px-2 py-0.5 rounded-lg text-xs font-medium ${marginClass}`}>
864
+ {row.margin !== undefined ? row.margin.toFixed(1) : '0.0'}%
865
+ </span>
866
+ </td>
867
+ <td className="px-6 py-4 text-center">
868
+ <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${row.status === 'Fulfilled' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
869
+ }`}>
870
+ {row.status}
871
+ </span>
872
+ </td>
873
+ </tr>
874
+ );
875
+ })}
876
+ </tbody>
877
+ </table>
878
+ </div>
879
+
880
+ {/* Pagination */}
881
+ {totalPages > 1 && (
882
+ <div className="flex items-center justify-between px-6 py-4 border-t border-border-light bg-slate-50">
883
+ <p className="text-sm text-text-secondary">
884
+ Showing {((page - 1) * ordersPerPage) + 1} to {Math.min(page * ordersPerPage, totalOrders)} of {totalOrders} orders
885
+ </p>
886
+ <div className="flex items-center gap-2">
887
+ <button
888
+ onClick={() => setPage((p: number) => Math.max(1, p - 1))}
889
+ disabled={page === 1}
890
+ className="px-3 py-1.5 text-sm font-medium rounded-lg border border-border-light bg-white hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
891
+ >
892
+ <span className="material-symbols-outlined text-[16px]">chevron_left</span>
893
+ Previous
894
+ </button>
895
+ <div className="flex items-center gap-1">
896
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
897
+ let pageNum;
898
+ if (totalPages <= 5) {
899
+ pageNum = i + 1;
900
+ } else if (page <= 3) {
901
+ pageNum = i + 1;
902
+ } else if (page >= totalPages - 2) {
903
+ pageNum = totalPages - 4 + i;
904
+ } else {
905
+ pageNum = page - 2 + i;
906
+ }
907
+ return (
908
+ <button
909
+ key={pageNum}
910
+ onClick={() => setPage(pageNum)}
911
+ className={`w-8 h-8 text-sm font-medium rounded-lg ${page === pageNum
912
+ ? 'bg-primary text-white'
913
+ : 'bg-white border border-border-light hover:bg-slate-50'
914
+ }`}
915
+ >
916
+ {pageNum}
917
+ </button>
918
+ );
919
+ })}
920
+ </div>
921
+ <button
922
+ onClick={() => setPage((p: number) => Math.min(totalPages, p + 1))}
923
+ disabled={page === totalPages}
924
+ className="px-3 py-1.5 text-sm font-medium rounded-lg border border-border-light bg-white hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
925
+ >
926
+ Next
927
+ <span className="material-symbols-outlined text-[16px]">chevron_right</span>
928
+ </button>
929
+ </div>
930
+ </div>
931
+ )}
932
+ </>
933
+ )}
934
+ </div>
935
+ );
936
+ };
937
+
938
+ export default ProfitAnalytics;
frontend/pages/Settings.tsx ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { resetDatabase, resetShopData } from '../services/api';
3
+ import { useShop } from '../contexts/ShopContext';
4
+
5
+ const Settings: React.FC = () => {
6
+ const { currentShop, refreshShops } = useShop();
7
+ const [loading, setLoading] = useState(false);
8
+ const [message, setMessage] = useState<string | null>(null);
9
+ const [showDangerZone, setShowDangerZone] = useState(false);
10
+
11
+ const handleResetCurrentShop = async () => {
12
+ if (!currentShop) {
13
+ alert("Please select a shop first.");
14
+ return;
15
+ }
16
+
17
+ const confirmText = "RESET SHOP";
18
+ const userInput = prompt(`⚠️ WARNING: This will permanently delete ALL data for "${currentShop.name}" (orders, imports, providers).\n\nType "${confirmText}" to confirm:`);
19
+
20
+ if (userInput !== confirmText) {
21
+ if (userInput !== null) {
22
+ alert("Confirmation text did not match. Reset cancelled.");
23
+ }
24
+ return;
25
+ }
26
+
27
+ setLoading(true);
28
+ try {
29
+ await resetShopData(currentShop.id);
30
+ await refreshShops();
31
+ setMessage(`Shop "${currentShop.name}" data has been reset successfully.`);
32
+ } catch (e) {
33
+ setMessage("Failed to reset shop data. Please try again.");
34
+ } finally {
35
+ setLoading(false);
36
+ }
37
+ }
38
+
39
+ const handleResetAllDatabase = async () => {
40
+ const confirmText = "DELETE EVERYTHING";
41
+ const userInput = prompt(`⚠️ EXTREME WARNING: This will permanently delete ALL shops and ALL data. This cannot be undone!\n\nType "${confirmText}" to confirm:`);
42
+
43
+ if (userInput !== confirmText) {
44
+ if (userInput !== null) {
45
+ alert("Confirmation text did not match. Reset cancelled.");
46
+ }
47
+ return;
48
+ }
49
+
50
+ setLoading(true);
51
+ try {
52
+ await resetDatabase();
53
+ await refreshShops();
54
+ setMessage("Entire database has been reset. All shops and data deleted.");
55
+ } catch (e) {
56
+ setMessage("Failed to reset database. Please try again.");
57
+ } finally {
58
+ setLoading(false);
59
+ }
60
+ }
61
+
62
+ return (
63
+ <div className="max-w-[800px] mx-auto flex flex-col gap-6">
64
+ <div>
65
+ <h2 className="text-3xl font-black text-text-main">Settings</h2>
66
+ <p className="text-text-secondary mt-2">Configure your application preferences and manage data.</p>
67
+ </div>
68
+
69
+ {message && (
70
+ <div className={`p-4 rounded-lg border ${message.includes('Failed') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 border-green-200 text-green-700'}`}>
71
+ <p className="font-medium flex items-center gap-2">
72
+ <span className="material-symbols-outlined">{message.includes('Failed') ? 'error' : 'check_circle'}</span>
73
+ {message}
74
+ </p>
75
+ </div>
76
+ )}
77
+
78
+ {/* General Settings */}
79
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
80
+ <div className="flex items-center gap-3 mb-4">
81
+ <div className="size-10 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center">
82
+ <span className="material-symbols-outlined">tune</span>
83
+ </div>
84
+ <h3 className="text-lg font-bold text-text-main">General</h3>
85
+ </div>
86
+ <p className="text-text-secondary text-sm">
87
+ Configure your API keys and Shop preferences here. (Coming soon)
88
+ </p>
89
+ </div>
90
+
91
+ {/* Multi-Shop Info */}
92
+ <div className="bg-white rounded-xl border border-border-light p-6 shadow-sm">
93
+ <div className="flex items-center gap-3 mb-4">
94
+ <div className="size-10 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center">
95
+ <span className="material-symbols-outlined">storefront</span>
96
+ </div>
97
+ <h3 className="text-lg font-bold text-text-main">Multi-Shop Mode</h3>
98
+ </div>
99
+ <div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
100
+ <p className="text-sm text-emerald-800">
101
+ <strong>✓ Active:</strong> Your application is running in multi-shop mode. Each shop has completely isolated data.
102
+ </p>
103
+ <ul className="mt-2 text-sm text-emerald-700 list-disc list-inside space-y-1">
104
+ <li>Orders are stored per-shop (same Order ID can exist in multiple shops)</li>
105
+ <li>Providers are managed per-shop</li>
106
+ <li>Matching algorithm only works within a single shop</li>
107
+ <li>Deleting a shop removes all its data permanently</li>
108
+ </ul>
109
+ </div>
110
+ {currentShop && (
111
+ <div className="mt-4 p-3 bg-slate-50 rounded-lg border border-border-light">
112
+ <p className="text-sm text-text-secondary">
113
+ Currently selected: <strong className="text-primary">{currentShop.name}</strong>
114
+ </p>
115
+ </div>
116
+ )}
117
+ </div>
118
+
119
+ {/* Danger Zone - Collapsible */}
120
+ <div className="bg-white rounded-xl border border-border-light shadow-sm overflow-hidden">
121
+ <button
122
+ onClick={() => setShowDangerZone(!showDangerZone)}
123
+ className="w-full px-6 py-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
124
+ >
125
+ <div className="flex items-center gap-3">
126
+ <div className="size-10 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
127
+ <span className="material-symbols-outlined">warning</span>
128
+ </div>
129
+ <div className="text-left">
130
+ <h3 className="text-lg font-bold text-text-main">Danger Zone</h3>
131
+ <p className="text-sm text-text-secondary">Advanced actions that can affect your data</p>
132
+ </div>
133
+ </div>
134
+ <span className={`material-symbols-outlined text-text-secondary transition-transform ${showDangerZone ? 'rotate-180' : ''}`}>
135
+ expand_more
136
+ </span>
137
+ </button>
138
+
139
+ {showDangerZone && (
140
+ <div className="px-6 pb-6 pt-2 border-t border-border-light bg-red-50/50 space-y-4">
141
+ {/* Reset Current Shop */}
142
+ {currentShop && (
143
+ <div className="bg-white rounded-lg border border-amber-200 p-4">
144
+ <div className="flex items-start gap-4">
145
+ <span className="material-symbols-outlined text-amber-600 text-2xl mt-0.5">refresh</span>
146
+ <div className="flex-1">
147
+ <h4 className="font-bold text-amber-800 mb-1">Reset Current Shop</h4>
148
+ <p className="text-sm text-amber-600 mb-3">
149
+ Delete all data (orders, imports, providers) for <strong>{currentShop.name}</strong> but keep the shop itself.
150
+ </p>
151
+ <button
152
+ onClick={handleResetCurrentShop}
153
+ disabled={loading}
154
+ className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-lg transition-colors disabled:opacity-50"
155
+ >
156
+ {loading ? 'Processing...' : `Reset "${currentShop.name}" Data`}
157
+ </button>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ )}
162
+
163
+ {/* Reset Everything */}
164
+ <div className="bg-white rounded-lg border border-red-200 p-4">
165
+ <div className="flex items-start gap-4">
166
+ <span className="material-symbols-outlined text-red-600 text-2xl mt-0.5">delete_forever</span>
167
+ <div className="flex-1">
168
+ <h4 className="font-bold text-red-800 mb-1">Reset Entire Database</h4>
169
+ <p className="text-sm text-red-600 mb-3">
170
+ Permanently delete ALL shops and ALL data. This action cannot be undone.
171
+ </p>
172
+ <button
173
+ onClick={handleResetAllDatabase}
174
+ disabled={loading}
175
+ className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
176
+ >
177
+ {loading ? (
178
+ <span className="flex items-center gap-2">
179
+ <span className="animate-spin">⏳</span>
180
+ Processing...
181
+ </span>
182
+ ) : (
183
+ <span className="flex items-center gap-2">
184
+ <span className="material-symbols-outlined text-lg">delete_forever</span>
185
+ Reset Entire Database
186
+ </span>
187
+ )}
188
+ </button>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ )}
194
+ </div>
195
+ </div>
196
+ );
197
+ };
198
+
199
+ export default Settings;
frontend/pages/ShopManagement.tsx ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useShop } from '../contexts/ShopContext';
3
+ import { Shop } from '../types';
4
+ import { createShop, deleteShop, resetShopData, updateShop } from '../services/api';
5
+
6
+ const ShopManagement: React.FC = () => {
7
+ const { shops, currentShop, setCurrentShop, refreshShops, isLoading } = useShop();
8
+ const [showCreateModal, setShowCreateModal] = useState(false);
9
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState<Shop | null>(null);
10
+ const [showResetConfirm, setShowResetConfirm] = useState<Shop | null>(null);
11
+ const [editingShop, setEditingShop] = useState<Shop | null>(null);
12
+
13
+ // Form states
14
+ const [formName, setFormName] = useState('');
15
+ const [formSlug, setFormSlug] = useState('');
16
+ const [formDescription, setFormDescription] = useState('');
17
+ const [formError, setFormError] = useState('');
18
+ const [isSubmitting, setIsSubmitting] = useState(false);
19
+
20
+ const handleCreateShop = async () => {
21
+ if (!formName.trim() || !formSlug.trim()) {
22
+ setFormError('Name and Slug are required');
23
+ return;
24
+ }
25
+
26
+ setIsSubmitting(true);
27
+ setFormError('');
28
+ try {
29
+ await createShop(formName, formSlug, formDescription);
30
+ await refreshShops();
31
+ setShowCreateModal(false);
32
+ resetForm();
33
+ } catch (err) {
34
+ setFormError(err instanceof Error ? err.message : 'Failed to create shop');
35
+ } finally {
36
+ setIsSubmitting(false);
37
+ }
38
+ };
39
+
40
+ const handleUpdateShop = async () => {
41
+ if (!editingShop || !formName.trim()) {
42
+ setFormError('Name is required');
43
+ return;
44
+ }
45
+
46
+ setIsSubmitting(true);
47
+ setFormError('');
48
+ try {
49
+ await updateShop(editingShop.id, {
50
+ name: formName,
51
+ description: formDescription
52
+ });
53
+ await refreshShops();
54
+ setEditingShop(null);
55
+ resetForm();
56
+ } catch (err) {
57
+ setFormError(err instanceof Error ? err.message : 'Failed to update shop');
58
+ } finally {
59
+ setIsSubmitting(false);
60
+ }
61
+ };
62
+
63
+ const handleDeleteShop = async (shop: Shop) => {
64
+ setIsSubmitting(true);
65
+ try {
66
+ await deleteShop(shop.id);
67
+ if (currentShop?.id === shop.id) {
68
+ setCurrentShop(null);
69
+ }
70
+ await refreshShops();
71
+ setShowDeleteConfirm(null);
72
+ } catch (err) {
73
+ alert(err instanceof Error ? err.message : 'Failed to delete shop');
74
+ } finally {
75
+ setIsSubmitting(false);
76
+ }
77
+ };
78
+
79
+ const handleResetShop = async (shop: Shop) => {
80
+ setIsSubmitting(true);
81
+ try {
82
+ await resetShopData(shop.id);
83
+ await refreshShops();
84
+ setShowResetConfirm(null);
85
+ } catch (err) {
86
+ alert(err instanceof Error ? err.message : 'Failed to reset shop');
87
+ } finally {
88
+ setIsSubmitting(false);
89
+ }
90
+ };
91
+
92
+ const resetForm = () => {
93
+ setFormName('');
94
+ setFormSlug('');
95
+ setFormDescription('');
96
+ setFormError('');
97
+ };
98
+
99
+ const openEditModal = (shop: Shop) => {
100
+ setFormName(shop.name);
101
+ setFormDescription(shop.description || '');
102
+ setEditingShop(shop);
103
+ };
104
+
105
+ const generateSlug = (name: string) => {
106
+ return name
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9\s-]/g, '')
109
+ .replace(/\s+/g, '-')
110
+ .substring(0, 30);
111
+ };
112
+
113
+ if (isLoading) {
114
+ return (
115
+ <div className="flex items-center justify-center h-64">
116
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div className="max-w-6xl mx-auto space-y-6">
123
+ {/* Header */}
124
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
125
+ <div>
126
+ <h1 className="text-2xl font-bold text-text-main">Shop Management</h1>
127
+ <p className="text-text-secondary mt-1">Manage your Etsy shops. Each shop has completely isolated data.</p>
128
+ </div>
129
+ <button
130
+ onClick={() => { resetForm(); setShowCreateModal(true); }}
131
+ className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-primary to-primary/90 text-white rounded-xl font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all"
132
+ >
133
+ <span className="material-symbols-outlined">add</span>
134
+ Create New Shop
135
+ </button>
136
+ </div>
137
+
138
+ {/* Stats Overview */}
139
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
140
+ <div className="bg-white rounded-xl p-4 border border-border-light">
141
+ <p className="text-2xl font-bold text-primary">{shops.length}</p>
142
+ <p className="text-sm text-text-secondary">Total Shops</p>
143
+ </div>
144
+ <div className="bg-white rounded-xl p-4 border border-border-light">
145
+ <p className="text-2xl font-bold text-emerald-600">
146
+ {shops.reduce((acc, s) => acc + (s.order_count || 0), 0)}
147
+ </p>
148
+ <p className="text-sm text-text-secondary">Total Orders</p>
149
+ </div>
150
+ <div className="bg-white rounded-xl p-4 border border-border-light">
151
+ <p className="text-2xl font-bold text-blue-600">
152
+ {shops.reduce((acc, s) => acc + (s.provider_count || 0), 0)}
153
+ </p>
154
+ <p className="text-sm text-text-secondary">Total Providers</p>
155
+ </div>
156
+ <div className="bg-white rounded-xl p-4 border border-border-light">
157
+ <p className="text-2xl font-bold text-amber-600">
158
+ {shops.filter(s => s.is_active).length}
159
+ </p>
160
+ <p className="text-sm text-text-secondary">Active Shops</p>
161
+ </div>
162
+ </div>
163
+
164
+ {/* Shops Grid */}
165
+ {shops.length === 0 ? (
166
+ <div className="bg-white rounded-2xl border border-border-light p-12 text-center">
167
+ <div className="w-16 h-16 rounded-full bg-slate-100 mx-auto flex items-center justify-center mb-4">
168
+ <span className="material-symbols-outlined text-3xl text-slate-400">storefront</span>
169
+ </div>
170
+ <h3 className="text-lg font-semibold text-text-main mb-2">No Shops Yet</h3>
171
+ <p className="text-text-secondary mb-6">Create your first shop to start managing Etsy orders.</p>
172
+ <button
173
+ onClick={() => { resetForm(); setShowCreateModal(true); }}
174
+ className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl font-medium"
175
+ >
176
+ <span className="material-symbols-outlined">add</span>
177
+ Create Your First Shop
178
+ </button>
179
+ </div>
180
+ ) : (
181
+ <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
182
+ {shops.map((shop) => (
183
+ <div
184
+ key={shop.id}
185
+ className={`bg-white rounded-2xl border-2 p-5 transition-all hover:shadow-lg ${currentShop?.id === shop.id
186
+ ? 'border-primary shadow-primary/10'
187
+ : 'border-border-light hover:border-primary/30'
188
+ }`}
189
+ >
190
+ {/* Shop Header */}
191
+ <div className="flex items-start justify-between mb-4">
192
+ <div className="flex items-center gap-3">
193
+ <div className={`w-12 h-12 rounded-xl flex items-center justify-center ${currentShop?.id === shop.id
194
+ ? 'bg-gradient-to-br from-primary to-primary/70'
195
+ : 'bg-slate-100'
196
+ }`}>
197
+ <span className={`material-symbols-outlined text-2xl ${currentShop?.id === shop.id ? 'text-white' : 'text-slate-500'
198
+ }`}>storefront</span>
199
+ </div>
200
+ <div>
201
+ <h3 className="font-semibold text-text-main">{shop.name}</h3>
202
+ <p className="text-xs text-text-secondary">{shop.slug}</p>
203
+ </div>
204
+ </div>
205
+ {currentShop?.id === shop.id && (
206
+ <span className="px-2 py-1 text-xs font-medium bg-primary/10 text-primary rounded-full">
207
+ Active
208
+ </span>
209
+ )}
210
+ </div>
211
+
212
+ {/* Description */}
213
+ {shop.description && (
214
+ <p className="text-sm text-text-secondary mb-4 line-clamp-2">{shop.description}</p>
215
+ )}
216
+
217
+ {/* Stats */}
218
+ <div className="grid grid-cols-2 gap-3 mb-4">
219
+ <div className="bg-slate-50 rounded-lg p-3">
220
+ <p className="text-lg font-semibold text-text-main">{shop.order_count || 0}</p>
221
+ <p className="text-xs text-text-secondary">Orders</p>
222
+ </div>
223
+ <div className="bg-slate-50 rounded-lg p-3">
224
+ <p className="text-lg font-semibold text-text-main">{shop.provider_count || 0}</p>
225
+ <p className="text-xs text-text-secondary">Providers</p>
226
+ </div>
227
+ </div>
228
+
229
+ {/* Actions */}
230
+ <div className="flex flex-wrap gap-2">
231
+ <button
232
+ onClick={() => setCurrentShop(shop)}
233
+ disabled={currentShop?.id === shop.id}
234
+ className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${currentShop?.id === shop.id
235
+ ? 'bg-slate-100 text-slate-400 cursor-not-allowed'
236
+ : 'bg-primary text-white hover:bg-primary/90'
237
+ }`}
238
+ >
239
+ {currentShop?.id === shop.id ? 'Selected' : 'Select'}
240
+ </button>
241
+ <button
242
+ onClick={() => openEditModal(shop)}
243
+ className="p-2 text-text-secondary hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
244
+ title="Edit Shop"
245
+ >
246
+ <span className="material-symbols-outlined text-lg">edit</span>
247
+ </button>
248
+ <button
249
+ onClick={() => setShowResetConfirm(shop)}
250
+ className="p-2 text-amber-600 hover:bg-amber-50 rounded-lg transition-colors"
251
+ title="Reset Shop Data"
252
+ >
253
+ <span className="material-symbols-outlined text-lg">refresh</span>
254
+ </button>
255
+ <button
256
+ onClick={() => setShowDeleteConfirm(shop)}
257
+ className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
258
+ title="Delete Shop"
259
+ >
260
+ <span className="material-symbols-outlined text-lg">delete</span>
261
+ </button>
262
+ </div>
263
+ </div>
264
+ ))}
265
+ </div>
266
+ )}
267
+
268
+ {/* Create/Edit Modal */}
269
+ {(showCreateModal || editingShop) && (
270
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
271
+ <div className="bg-white rounded-2xl max-w-md w-full p-6 shadow-2xl">
272
+ <h2 className="text-xl font-bold text-text-main mb-4">
273
+ {editingShop ? 'Edit Shop' : 'Create New Shop'}
274
+ </h2>
275
+
276
+ {formError && (
277
+ <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
278
+ {formError}
279
+ </div>
280
+ )}
281
+
282
+ <div className="space-y-4">
283
+ <div>
284
+ <label className="block text-sm font-medium text-text-main mb-1">Shop Name *</label>
285
+ <input
286
+ type="text"
287
+ value={formName}
288
+ onChange={(e) => {
289
+ setFormName(e.target.value);
290
+ if (!editingShop) {
291
+ setFormSlug(generateSlug(e.target.value));
292
+ }
293
+ }}
294
+ placeholder="My Etsy Shop"
295
+ className="w-full px-4 py-2.5 border border-border-light rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none"
296
+ />
297
+ </div>
298
+
299
+ {!editingShop && (
300
+ <div>
301
+ <label className="block text-sm font-medium text-text-main mb-1">Slug *</label>
302
+ <input
303
+ type="text"
304
+ value={formSlug}
305
+ onChange={(e) => setFormSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
306
+ placeholder="my-etsy-shop"
307
+ className="w-full px-4 py-2.5 border border-border-light rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-mono text-sm"
308
+ />
309
+ <p className="mt-1 text-xs text-text-secondary">Unique identifier, lowercase letters and hyphens only</p>
310
+ </div>
311
+ )}
312
+
313
+ <div>
314
+ <label className="block text-sm font-medium text-text-main mb-1">Description</label>
315
+ <textarea
316
+ value={formDescription}
317
+ onChange={(e) => setFormDescription(e.target.value)}
318
+ placeholder="Optional description for this shop..."
319
+ rows={3}
320
+ className="w-full px-4 py-2.5 border border-border-light rounded-lg focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none resize-none"
321
+ />
322
+ </div>
323
+ </div>
324
+
325
+ <div className="flex gap-3 mt-6">
326
+ <button
327
+ onClick={() => { setShowCreateModal(false); setEditingShop(null); resetForm(); }}
328
+ className="flex-1 px-4 py-2.5 border border-border-light rounded-lg font-medium text-text-secondary hover:bg-slate-50 transition-colors"
329
+ >
330
+ Cancel
331
+ </button>
332
+ <button
333
+ onClick={editingShop ? handleUpdateShop : handleCreateShop}
334
+ disabled={isSubmitting}
335
+ className="flex-1 px-4 py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
336
+ >
337
+ {isSubmitting ? 'Saving...' : (editingShop ? 'Save Changes' : 'Create Shop')}
338
+ </button>
339
+ </div>
340
+ </div>
341
+ </div>
342
+ )}
343
+
344
+ {/* Delete Confirmation Modal */}
345
+ {showDeleteConfirm && (
346
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
347
+ <div className="bg-white rounded-2xl max-w-md w-full p-6 shadow-2xl">
348
+ <div className="w-12 h-12 rounded-full bg-red-100 mx-auto flex items-center justify-center mb-4">
349
+ <span className="material-symbols-outlined text-2xl text-red-500">warning</span>
350
+ </div>
351
+ <h2 className="text-xl font-bold text-text-main text-center mb-2">Delete Shop?</h2>
352
+ <p className="text-text-secondary text-center mb-6">
353
+ This will permanently delete <strong>{showDeleteConfirm.name}</strong> and ALL its data including orders, providers, and import history. This action cannot be undone.
354
+ </p>
355
+ <div className="flex gap-3">
356
+ <button
357
+ onClick={() => setShowDeleteConfirm(null)}
358
+ className="flex-1 px-4 py-2.5 border border-border-light rounded-lg font-medium text-text-secondary hover:bg-slate-50 transition-colors"
359
+ >
360
+ Cancel
361
+ </button>
362
+ <button
363
+ onClick={() => handleDeleteShop(showDeleteConfirm)}
364
+ disabled={isSubmitting}
365
+ className="flex-1 px-4 py-2.5 bg-red-500 text-white rounded-lg font-medium hover:bg-red-600 transition-colors disabled:opacity-50"
366
+ >
367
+ {isSubmitting ? 'Deleting...' : 'Delete Shop'}
368
+ </button>
369
+ </div>
370
+ </div>
371
+ </div>
372
+ )}
373
+
374
+ {/* Reset Confirmation Modal */}
375
+ {showResetConfirm && (
376
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
377
+ <div className="bg-white rounded-2xl max-w-md w-full p-6 shadow-2xl">
378
+ <div className="w-12 h-12 rounded-full bg-amber-100 mx-auto flex items-center justify-center mb-4">
379
+ <span className="material-symbols-outlined text-2xl text-amber-600">refresh</span>
380
+ </div>
381
+ <h2 className="text-xl font-bold text-text-main text-center mb-2">Reset Shop Data?</h2>
382
+ <p className="text-text-secondary text-center mb-6">
383
+ This will delete all data (orders, providers, imports) for <strong>{showResetConfirm.name}</strong> but keep the shop itself. This action cannot be undone.
384
+ </p>
385
+ <div className="flex gap-3">
386
+ <button
387
+ onClick={() => setShowResetConfirm(null)}
388
+ className="flex-1 px-4 py-2.5 border border-border-light rounded-lg font-medium text-text-secondary hover:bg-slate-50 transition-colors"
389
+ >
390
+ Cancel
391
+ </button>
392
+ <button
393
+ onClick={() => handleResetShop(showResetConfirm)}
394
+ disabled={isSubmitting}
395
+ className="flex-1 px-4 py-2.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition-colors disabled:opacity-50"
396
+ >
397
+ {isSubmitting ? 'Resetting...' : 'Reset Data'}
398
+ </button>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ )}
403
+ </div>
404
+ );
405
+ };
406
+
407
+ export default ShopManagement;
frontend/services/api.ts ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ProfitData, Order, FulfillmentProvider, Shop, ShopSummary, AllShopsSummaryResponse, AllShopsOrder, AllShopsProfitData, ShopSummaryResponse } from '../types';
2
+
3
+ // CONFIGURATION
4
+ export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
5
+
6
+ if (!API_BASE_URL) {
7
+ console.error("CRITICAL: VITE_API_BASE_URL is missing. The app cannot connect to the backend.");
8
+ }
9
+
10
+ // ==========================================
11
+ // SHOP MANAGEMENT APIs
12
+ // ==========================================
13
+
14
+ export const fetchShops = async (): Promise<Shop[]> => {
15
+ const response = await fetch(`${API_BASE_URL}/api/shops`);
16
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
17
+ return await response.json();
18
+ };
19
+
20
+ export const createShop = async (name: string, slug: string, description?: string): Promise<Shop> => {
21
+ const formData = new FormData();
22
+ formData.append('name', name);
23
+ formData.append('slug', slug);
24
+ if (description) formData.append('description', description);
25
+
26
+ const response = await fetch(`${API_BASE_URL}/api/shops`, {
27
+ method: 'POST',
28
+ body: formData
29
+ });
30
+ if (!response.ok) {
31
+ const error = await response.json().catch(() => ({ detail: response.statusText }));
32
+ throw new Error(error.detail || 'Failed to create shop');
33
+ }
34
+ return await response.json();
35
+ };
36
+
37
+ export const updateShop = async (
38
+ shopId: number,
39
+ data: { name?: string; description?: string; is_active?: boolean }
40
+ ): Promise<void> => {
41
+ const formData = new FormData();
42
+ if (data.name) formData.append('name', data.name);
43
+ if (data.description !== undefined) formData.append('description', data.description);
44
+ if (data.is_active !== undefined) formData.append('is_active', data.is_active.toString());
45
+
46
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}`, {
47
+ method: 'PUT',
48
+ body: formData
49
+ });
50
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
51
+ };
52
+
53
+ export const deleteShop = async (shopId: number): Promise<void> => {
54
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}`, {
55
+ method: 'DELETE'
56
+ });
57
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
58
+ };
59
+
60
+ export const resetShopData = async (shopId: number): Promise<void> => {
61
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/reset`, {
62
+ method: 'POST'
63
+ });
64
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
65
+ };
66
+
67
+ // ==========================================
68
+ // SHOP-SCOPED DATA APIs
69
+ // ==========================================
70
+
71
+ export const fetchShopSummary = async (shopId: number, startDate?: string, endDate?: string): Promise<ShopSummaryResponse> => {
72
+ let url = `${API_BASE_URL}/api/shops/${shopId}/analytics/summary`;
73
+ const params = new URLSearchParams();
74
+ if (startDate) params.append('start_date', startDate);
75
+ if (endDate) params.append('end_date', endDate);
76
+ if (params.toString()) url += `?${params.toString()}`;
77
+
78
+ const response = await fetch(url);
79
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
80
+ return await response.json();
81
+ }
82
+
83
+ export const fetchProfitData = async (shopId: number): Promise<ProfitData[]> => {
84
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/analytics/net-profit`);
85
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
86
+ return await response.json();
87
+ };
88
+
89
+ export const fetchOrders = async (shopId: number): Promise<Order[]> => {
90
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/orders`);
91
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
92
+ return await response.json();
93
+ }
94
+
95
+ export const uploadFile = async (shopId: number, type: 'etsy' | 'fulfillment', file: File, providerId?: number) => {
96
+ const formData = new FormData();
97
+ formData.append('file', file);
98
+ if (providerId) {
99
+ formData.append('provider_id', providerId.toString());
100
+ }
101
+
102
+ const endpoint = type === 'etsy'
103
+ ? `/api/shops/${shopId}/upload/etsy`
104
+ : `/api/shops/${shopId}/upload/fulfillment`;
105
+
106
+ const response = await fetch(`${API_BASE_URL}${endpoint}`, {
107
+ method: 'POST',
108
+ body: formData,
109
+ });
110
+
111
+ if (!response.ok) {
112
+ throw new Error(`Upload failed: ${response.statusText}`);
113
+ }
114
+ return await response.json();
115
+ };
116
+
117
+ export const createProvider = async (shopId: number, name: string, mapping: any) => {
118
+ const formData = new FormData();
119
+ formData.append('name', name);
120
+ formData.append('mapping_config', JSON.stringify(mapping));
121
+
122
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/providers`, {
123
+ method: 'POST',
124
+ body: formData
125
+ });
126
+ if (!response.ok) throw new Error('Failed to create provider');
127
+ return await response.json();
128
+ }
129
+
130
+ export const fetchProviders = async (shopId: number): Promise<FulfillmentProvider[]> => {
131
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/providers`);
132
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
133
+ return await response.json();
134
+ }
135
+
136
+ export const fetchImportHistory = async (shopId: number, type: string, providerId?: number): Promise<any[]> => {
137
+ let url = `${API_BASE_URL}/api/shops/${shopId}/imports?import_type=${type}`;
138
+ if (providerId) {
139
+ url += `&provider_id=${providerId}`;
140
+ }
141
+ const response = await fetch(url);
142
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
143
+ return await response.json();
144
+ }
145
+
146
+ export const fetchImportDetails = async (shopId: number, id: number): Promise<{ id: number, file_name: string, raw_content: string }> => {
147
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/imports/${id}`);
148
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
149
+ return await response.json();
150
+ }
151
+
152
+ export const deleteImport = async (shopId: number, id: number) => {
153
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/imports/${id}`, {
154
+ method: 'DELETE'
155
+ });
156
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
157
+ return await response.json();
158
+ }
159
+
160
+ export const runMatching = async (shopId: number) => {
161
+ const response = await fetch(`${API_BASE_URL}/api/shops/${shopId}/actions/run-matching`, {
162
+ method: 'POST'
163
+ });
164
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
165
+ return await response.json();
166
+ }
167
+
168
+ // ==========================================
169
+ // CROSS-SHOP ANALYTICS APIs
170
+ // ==========================================
171
+
172
+ export const fetchAllShopsSummary = async (startDate?: string, endDate?: string): Promise<AllShopsSummaryResponse> => {
173
+ let url = `${API_BASE_URL}/api/analytics/all-shops/summary`;
174
+ const params = new URLSearchParams();
175
+ if (startDate) params.append('start_date', startDate);
176
+ if (endDate) params.append('end_date', endDate);
177
+ if (params.toString()) url += `?${params.toString()}`;
178
+
179
+ const response = await fetch(url);
180
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
181
+ return await response.json();
182
+ }
183
+
184
+ export const fetchAllShopsOrders = async (): Promise<AllShopsOrder[]> => {
185
+ const response = await fetch(`${API_BASE_URL}/api/analytics/all-shops/orders`);
186
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
187
+ return await response.json();
188
+ }
189
+
190
+ export const fetchAllShopsProfitData = async (): Promise<AllShopsProfitData[]> => {
191
+ const response = await fetch(`${API_BASE_URL}/api/analytics/all-shops/profit`);
192
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
193
+ return await response.json();
194
+ }
195
+
196
+ // ==========================================
197
+ // LEGACY/ADMIN APIs
198
+ // ==========================================
199
+
200
+ export const resetDatabase = async () => {
201
+ const response = await fetch(`${API_BASE_URL}/api/admin/reset-database`, {
202
+ method: 'POST'
203
+ });
204
+ if (!response.ok) throw new Error(`Backend error: ${response.statusText}`);
205
+ return await response.json();
206
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "types": [
14
+ "node"
15
+ ],
16
+ "moduleResolution": "bundler",
17
+ "isolatedModules": true,
18
+ "moduleDetection": "force",
19
+ "allowJs": true,
20
+ "jsx": "react-jsx",
21
+ "paths": {
22
+ "@/*": [
23
+ "./*"
24
+ ]
25
+ },
26
+ "allowImportingTsExtensions": true,
27
+ "noEmit": true
28
+ }
29
+ }
frontend/types.ts ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ==========================================
2
+ // SHOP TYPES
3
+ // ==========================================
4
+
5
+ export interface Shop {
6
+ id: number;
7
+ name: string;
8
+ slug: string;
9
+ description?: string;
10
+ created_at: string;
11
+ is_active: boolean;
12
+ order_count?: number;
13
+ provider_count?: number;
14
+ }
15
+
16
+ export interface ShopCreate {
17
+ name: string;
18
+ slug: string;
19
+ description?: string;
20
+ }
21
+
22
+ // ==========================================
23
+ // ORDER & ANALYTICS TYPES
24
+ // ==========================================
25
+
26
+ export interface ProfitData {
27
+ order_id: string;
28
+ internal_id: number;
29
+ sale_date: string;
30
+ customer: string;
31
+ revenue: number;
32
+ etsy_net: number;
33
+ cost: number;
34
+ profit: number;
35
+ margin: number;
36
+ status: 'Fulfilled' | 'Unfulfilled';
37
+ match_info: string[];
38
+ }
39
+
40
+ export interface Order {
41
+ id: string;
42
+ internal_id: number;
43
+ date: string;
44
+ customer: string;
45
+ items: string;
46
+ total: number;
47
+ status: 'Shipped' | 'Pending' | 'Completed' | 'Cancelled';
48
+ fulfillment_cost?: number;
49
+ match_info?: string[];
50
+ }
51
+
52
+ // ==========================================
53
+ // FULFILLMENT TYPES
54
+ // ==========================================
55
+
56
+ export interface FulfillmentProvider {
57
+ id: number;
58
+ name: string;
59
+ mapping_config: Record<string, string>;
60
+ }
61
+
62
+ export enum UploadType {
63
+ ETSY = 'etsy',
64
+ FULFILLMENT = 'fulfillment'
65
+ }
66
+
67
+ // ==========================================
68
+ // IMPORT TYPES
69
+ // ==========================================
70
+
71
+ export interface ImportHistory {
72
+ id: number;
73
+ date: string;
74
+ file_name: string;
75
+ count: number;
76
+ provider_id?: number;
77
+ }
78
+
79
+ // ==========================================
80
+ // CROSS-SHOP ANALYTICS
81
+ // ==========================================
82
+
83
+ export interface ShopSummary {
84
+ shop_id: number;
85
+ shop_name: string;
86
+ order_count: number;
87
+ fulfilled_count: number;
88
+ fulfillment_rate: number;
89
+ total_revenue: number;
90
+ total_cost: number;
91
+ total_profit: number;
92
+ profit_margin: number;
93
+ avg_order_value: number;
94
+ }
95
+
96
+ export interface AllShopsAggregates {
97
+ total_revenue: number;
98
+ total_cost: number;
99
+ total_profit: number;
100
+ total_orders: number;
101
+ total_fulfilled: number;
102
+ overall_margin: number;
103
+ overall_fulfillment_rate: number;
104
+ shop_count: number;
105
+ }
106
+
107
+ export interface AllShopsChanges {
108
+ revenue_change: number | null;
109
+ cost_change: number | null;
110
+ profit_change: number | null;
111
+ orders_change: number | null;
112
+ fulfillment_rate_change: number | null;
113
+ }
114
+
115
+ export interface AllShopsSummaryResponse {
116
+ shops: ShopSummary[];
117
+ aggregates: AllShopsAggregates;
118
+ changes: AllShopsChanges;
119
+ previous_period: AllShopsAggregates | null;
120
+ best_performer: ShopSummary | null;
121
+ worst_performer: ShopSummary | null;
122
+ }
123
+
124
+ export interface ShopSummaryResponse {
125
+ shop: {
126
+ id: number;
127
+ name: string;
128
+ slug: string;
129
+ };
130
+ aggregates: {
131
+ total_revenue: number;
132
+ total_cost: number;
133
+ total_profit: number;
134
+ total_orders: number;
135
+ total_fulfilled: number;
136
+ profit_margin: number;
137
+ fulfillment_rate: number;
138
+ avg_order_value: number;
139
+ };
140
+ changes: AllShopsChanges;
141
+ previous_period: {
142
+ total_revenue: number;
143
+ total_cost: number;
144
+ total_profit: number;
145
+ total_orders: number;
146
+ total_fulfilled: number;
147
+ profit_margin: number;
148
+ fulfillment_rate: number;
149
+ avg_order_value: number;
150
+ } | null;
151
+ }
152
+
153
+ export interface AllShopsOrder extends Order {
154
+ shop_id: number;
155
+ shop_name: string;
156
+ }
157
+
158
+ export interface AllShopsProfitData extends ProfitData {
159
+ shop_id: number;
160
+ shop_name: string;
161
+ }
162
+
163
+
164
+ // ==========================================
165
+ // AUTH TYPES
166
+ // ==========================================
167
+
168
+ export interface User {
169
+ id: number;
170
+ username: string;
171
+ role: 'admin' | 'user';
172
+ permissions: string[];
173
+ is_active: boolean;
174
+ created_at: string;
175
+ }
176
+
177
+ export interface LoginResponse {
178
+ access_token: string;
179
+ token_type: string;
180
+ }
181
+
182
+ export interface UserCreate {
183
+ username: string;
184
+ password?: string;
185
+ role: 'admin' | 'user';
186
+ permissions: string[];
187
+ }
188
+
frontend/vite.config.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ export default defineConfig(({ mode }) => {
6
+ const env = loadEnv(mode, '.', '');
7
+ return {
8
+ server: {
9
+ port: 3000,
10
+ host: '0.0.0.0',
11
+ },
12
+ plugins: [react()],
13
+ define: {
14
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
15
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
16
+ },
17
+ resolve: {
18
+ alias: {
19
+ '@': path.resolve(__dirname, '.'),
20
+ }
21
+ }
22
+ };
23
+ });
main.py ADDED
@@ -0,0 +1,1516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pandas as pd
3
+ import io
4
+ import re
5
+ import json
6
+ from typing import List, Optional, Dict, Any
7
+ from datetime import datetime, timedelta
8
+
9
+ from fastapi import FastAPI, UploadFile, File, Depends, HTTPException, Form, Path, status
10
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
11
+ from fastapi.responses import RedirectResponse
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from pydantic import BaseModel
14
+ from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Boolean, Date, JSON, or_, text, UniqueConstraint
15
+ from sqlalchemy.ext.declarative import declarative_base
16
+ from sqlalchemy.orm import sessionmaker, Session, relationship, joinedload, subqueryload
17
+ from passlib.context import CryptContext
18
+ from jose import JWTError, jwt
19
+
20
+ # ==========================================
21
+ # 1. DATABASE CONFIGURATION
22
+ # ==========================================
23
+ from dotenv import load_dotenv
24
+
25
+ # Load environment variables from .env file (if it exists)
26
+ load_dotenv()
27
+
28
+ # ==========================================
29
+ # 1. DATABASE CONFIGURATION
30
+ # ==========================================
31
+ # STRICT MODE: Requires DATABASE_URL in environment variables or .env file
32
+ SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL")
33
+
34
+ if not SQLALCHEMY_DATABASE_URL:
35
+ raise ValueError(
36
+ "CRITICAL ERROR: DATABASE_URL is missing!\n"
37
+ " - If running locally: Create a .env file (copy from .env.example) and define DATABASE_URL.\n"
38
+ " - If running on Render: Add DATABASE_URL to 'Environment Variables'."
39
+ )
40
+
41
+ engine = create_engine(SQLALCHEMY_DATABASE_URL)
42
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
43
+ Base = declarative_base()
44
+
45
+ # ==========================================
46
+ # 2. DATABASE MODELS - MULTI-SHOP ARCHITECTURE
47
+ # ==========================================
48
+
49
+ class User(Base):
50
+ __tablename__ = "users"
51
+
52
+ id = Column(Integer, primary_key=True, index=True)
53
+ username = Column(String, unique=True, index=True, nullable=False)
54
+ hashed_password = Column(String, nullable=False)
55
+ role = Column(String, default="user") # 'admin', 'user'
56
+ permissions = Column(JSON, default=[]) # List of permission strings
57
+ is_active = Column(Boolean, default=True)
58
+ created_at = Column(DateTime, default=datetime.utcnow)
59
+
60
+ class Shop(Base):
61
+ """Master Shop entity - each shop is completely isolated"""
62
+ __tablename__ = "shops"
63
+
64
+ id = Column(Integer, primary_key=True, index=True)
65
+ name = Column(String, nullable=False)
66
+ slug = Column(String, unique=True, nullable=False, index=True)
67
+ description = Column(String, nullable=True)
68
+ created_at = Column(DateTime, default=datetime.utcnow)
69
+ is_active = Column(Boolean, default=True)
70
+
71
+ # Relationships - CASCADE DELETE ensures complete isolation
72
+ orders = relationship("EtsyOrder", back_populates="shop", cascade="all, delete-orphan")
73
+ providers = relationship("FulfillmentProvider", back_populates="shop", cascade="all, delete-orphan")
74
+ imports = relationship("ImportHistory", back_populates="shop", cascade="all, delete-orphan")
75
+ fulfillment_records = relationship("FulfillmentRecord", back_populates="shop", cascade="all, delete-orphan")
76
+
77
+
78
+ class ImportHistory(Base):
79
+ __tablename__ = "import_history"
80
+
81
+ id = Column(Integer, primary_key=True, index=True)
82
+ shop_id = Column(Integer, ForeignKey("shops.id", ondelete="CASCADE"), nullable=False, index=True)
83
+ import_type = Column(String) # 'etsy' or 'fulfillment'
84
+ file_name = Column(String)
85
+ upload_date = Column(DateTime, default=datetime.utcnow)
86
+ record_count = Column(Integer, default=0)
87
+ provider_id = Column(Integer, ForeignKey("fulfillment_providers.id", ondelete="SET NULL"), nullable=True)
88
+ raw_content = Column(String)
89
+
90
+ # Relationships
91
+ shop = relationship("Shop", back_populates="imports")
92
+ etsy_orders = relationship("EtsyOrder", back_populates="import_source", cascade="all, delete-orphan")
93
+ fulfillment_records = relationship("FulfillmentRecord", back_populates="import_source", cascade="all, delete-orphan", foreign_keys="FulfillmentRecord.import_id")
94
+
95
+
96
+ class EtsyOrder(Base):
97
+ __tablename__ = "etsy_orders"
98
+ __table_args__ = (
99
+ UniqueConstraint('shop_id', 'order_id', name='uq_shop_order'),
100
+ )
101
+
102
+ id = Column(Integer, primary_key=True, index=True) # New auto-increment PK
103
+ shop_id = Column(Integer, ForeignKey("shops.id", ondelete="CASCADE"), nullable=False, index=True)
104
+ order_id = Column(String, nullable=False, index=True) # Etsy's order ID (not globally unique anymore)
105
+ sale_date = Column(Date)
106
+
107
+ full_name = Column(String)
108
+ first_name = Column(String)
109
+ last_name = Column(String)
110
+ buyer_user_id = Column(String)
111
+ email = Column(String, nullable=True)
112
+
113
+ street_1 = Column(String)
114
+ street_2 = Column(String, nullable=True)
115
+ ship_city = Column(String)
116
+ ship_state = Column(String)
117
+ ship_zipcode = Column(String, index=True)
118
+ ship_country = Column(String)
119
+
120
+ currency = Column(String, default="USD")
121
+ order_value = Column(Float, default=0.0)
122
+ order_total = Column(Float, default=0.0)
123
+ order_net = Column(Float, default=0.0)
124
+ shipping_revenue = Column(Float, default=0.0)
125
+
126
+ skus = Column(String)
127
+
128
+ import_id = Column(Integer, ForeignKey("import_history.id", ondelete="SET NULL"), nullable=True)
129
+
130
+ # Relationships
131
+ shop = relationship("Shop", back_populates="orders")
132
+ import_source = relationship("ImportHistory", back_populates="etsy_orders")
133
+ fulfillment_records = relationship("FulfillmentRecord", back_populates="etsy_order")
134
+
135
+
136
+ class FulfillmentProvider(Base):
137
+ __tablename__ = "fulfillment_providers"
138
+ __table_args__ = (
139
+ UniqueConstraint('shop_id', 'name', name='uq_shop_provider_name'),
140
+ )
141
+
142
+ id = Column(Integer, primary_key=True, index=True)
143
+ shop_id = Column(Integer, ForeignKey("shops.id", ondelete="CASCADE"), nullable=False, index=True)
144
+ name = Column(String, nullable=False)
145
+ mapping_config = Column(JSON, default={})
146
+
147
+ # Relationships
148
+ shop = relationship("Shop", back_populates="providers")
149
+ fulfillment_records = relationship("FulfillmentRecord", back_populates="provider")
150
+
151
+
152
+ class FulfillmentRecord(Base):
153
+ __tablename__ = "fulfillment_records"
154
+
155
+ id = Column(Integer, primary_key=True, index=True)
156
+ shop_id = Column(Integer, ForeignKey("shops.id", ondelete="CASCADE"), nullable=False, index=True)
157
+ provider_id = Column(Integer, ForeignKey("fulfillment_providers.id", ondelete="SET NULL"), nullable=True)
158
+
159
+ ref_id_value = Column(String, index=True)
160
+ total_cost = Column(Float, default=0.0)
161
+ tracking_number = Column(String, nullable=True)
162
+
163
+ raw_data = Column(JSON)
164
+
165
+ is_matched = Column(Boolean, default=False)
166
+ match_method = Column(String, nullable=True)
167
+
168
+ etsy_order_id = Column(Integer, ForeignKey("etsy_orders.id", ondelete="SET NULL"), nullable=True)
169
+ import_id = Column(Integer, ForeignKey("import_history.id", ondelete="SET NULL"), nullable=True)
170
+
171
+ # Relationships
172
+ shop = relationship("Shop", back_populates="fulfillment_records")
173
+ provider = relationship("FulfillmentProvider", back_populates="fulfillment_records")
174
+ etsy_order = relationship("EtsyOrder", back_populates="fulfillment_records")
175
+ import_source = relationship("ImportHistory", back_populates="fulfillment_records", foreign_keys=[import_id])
176
+
177
+
178
+ # ==========================================
179
+ # 3. DATABASE MIGRATION
180
+ # ==========================================
181
+
182
+ def run_migrations(engine):
183
+ """Run migrations to add new columns for multi-shop support"""
184
+ with engine.connect() as conn:
185
+ conn = conn.execution_options(isolation_level="AUTOCOMMIT")
186
+
187
+ # Check if shops table exists, if not the schema is fresh
188
+ try:
189
+ conn.execute(text("SELECT 1 FROM shops LIMIT 1"))
190
+ except Exception:
191
+ # Fresh database, no migration needed
192
+ return
193
+
194
+ # Add shop_id columns if missing (for existing installations)
195
+ migrations = [
196
+ ("etsy_orders", "shop_id", "INTEGER REFERENCES shops(id) ON DELETE CASCADE"),
197
+ ("fulfillment_providers", "shop_id", "INTEGER REFERENCES shops(id) ON DELETE CASCADE"),
198
+ ("fulfillment_records", "shop_id", "INTEGER REFERENCES shops(id) ON DELETE CASCADE"),
199
+ ("import_history", "shop_id", "INTEGER REFERENCES shops(id) ON DELETE CASCADE"),
200
+ ("users", "permissions", "JSON DEFAULT '[]'"),
201
+ ]
202
+
203
+ for table, column, definition in migrations:
204
+ try:
205
+ conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}"))
206
+ print(f"Migrated: Added {column} to {table}")
207
+ except Exception:
208
+ pass # Column likely exists
209
+
210
+ # Create all tables
211
+ Base.metadata.create_all(bind=engine)
212
+ run_migrations(engine)
213
+
214
+
215
+ # ==========================================
216
+ # 4. HELPER FUNCTIONS
217
+ # ==========================================
218
+
219
+ # Security Config
220
+ # Security Config
221
+ SECRET_KEY = os.getenv("API_SECRET_KEY", "changeme_in_production_please_9bd5644faf")
222
+ ALGORITHM = "HS256"
223
+ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours for convenience
224
+
225
+ pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
226
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
227
+
228
+ def verify_password(plain_password, hashed_password):
229
+ return pwd_context.verify(plain_password, hashed_password)
230
+
231
+ def get_password_hash(password):
232
+ return pwd_context.hash(password)
233
+
234
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
235
+ to_encode = data.copy()
236
+ if expires_delta:
237
+ expire = datetime.utcnow() + expires_delta
238
+ else:
239
+ expire = datetime.utcnow() + timedelta(minutes=15)
240
+ to_encode.update({"exp": expire})
241
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
242
+ return encoded_jwt
243
+
244
+ def get_db():
245
+ db = SessionLocal()
246
+ try:
247
+ yield db
248
+ finally:
249
+ db.close()
250
+
251
+ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
252
+ credentials_exception = HTTPException(
253
+ status_code=status.HTTP_401_UNAUTHORIZED,
254
+ detail="Could not validate credentials",
255
+ headers={"WWW-Authenticate": "Bearer"},
256
+ )
257
+ try:
258
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
259
+ username: str = payload.get("sub")
260
+ if username is None:
261
+ raise credentials_exception
262
+ token_data = TokenData(username=username)
263
+ except JWTError:
264
+ raise credentials_exception
265
+ user = db.query(User).filter(User.username == token_data.username).first()
266
+ if user is None:
267
+ raise credentials_exception
268
+ return user
269
+
270
+ async def get_current_active_user(current_user: User = Depends(get_current_user)):
271
+ if not current_user.is_active:
272
+ raise HTTPException(status_code=400, detail="Inactive user")
273
+ return current_user
274
+
275
+ async def get_admin_user(current_user: User = Depends(get_current_active_user)):
276
+ if current_user.role != "admin":
277
+ raise HTTPException(
278
+ status_code=status.HTTP_403_FORBIDDEN,
279
+ detail="The user doesn't have enough privileges"
280
+ )
281
+ return current_user
282
+
283
+ def get_db():
284
+ db = SessionLocal()
285
+ try:
286
+ yield db
287
+ finally:
288
+ db.close()
289
+
290
+ def clean_currency(value):
291
+ if pd.isna(value) or value == "":
292
+ return 0.0
293
+ if isinstance(value, (int, float)):
294
+ return float(value)
295
+ clean_str = str(value).lower().replace('$', '').replace('usd', '').replace('eur', '').strip()
296
+ clean_str = clean_str.replace(',', '')
297
+ try:
298
+ match = re.search(r'(\d+(\.\d+)?)', clean_str)
299
+ if match:
300
+ return float(match.group(1))
301
+ return 0.0
302
+ except ValueError:
303
+ return 0.0
304
+
305
+ def clean_value(value):
306
+ """Convert pandas NaN to None for database insertion"""
307
+ if pd.isna(value):
308
+ return None
309
+ return value
310
+
311
+ def safe_str(value, default=""):
312
+ """Safely convert value to string, handling NaN"""
313
+ if pd.isna(value) or value is None:
314
+ return default
315
+ return str(value)
316
+
317
+ def normalize_search_text(s):
318
+ if not s or pd.isna(s):
319
+ return ""
320
+ s = str(s).lower()
321
+ s = re.sub(r'[^\w\s]', ' ', s)
322
+ return re.sub(r'\s+', ' ', s).strip()
323
+
324
+ def extract_house_number(address):
325
+ if not address: return ""
326
+ match = re.search(r'\d+', str(address))
327
+ return match.group(0) if match else ""
328
+
329
+ def get_shop_or_404(db: Session, shop_id: int) -> Shop:
330
+ """Helper to get shop or raise 404"""
331
+ shop = db.query(Shop).filter(Shop.id == shop_id).first()
332
+ if not shop:
333
+ raise HTTPException(status_code=404, detail=f"Shop with id {shop_id} not found")
334
+ return shop
335
+
336
+
337
+ # ==========================================
338
+ # 5. PYDANTIC MODELS
339
+ # ==========================================
340
+
341
+ class Token(BaseModel):
342
+ access_token: str
343
+ token_type: str
344
+
345
+ class TokenData(BaseModel):
346
+ username: Optional[str] = None
347
+
348
+ class UserBase(BaseModel):
349
+ username: str
350
+
351
+ class UserCreate(UserBase):
352
+ password: str
353
+ role: str = "user"
354
+ permissions: List[str] = []
355
+
356
+ class UserResponse(UserBase):
357
+ id: int
358
+ role: str
359
+ permissions: List[str] = []
360
+ is_active: bool
361
+ created_at: datetime
362
+
363
+ class Config:
364
+ from_attributes = True
365
+
366
+ class ShopCreate(BaseModel):
367
+ name: str
368
+ slug: str
369
+ description: Optional[str] = None
370
+
371
+ class ShopUpdate(BaseModel):
372
+ name: Optional[str] = None
373
+ description: Optional[str] = None
374
+ is_active: Optional[bool] = None
375
+
376
+ class ProviderCreate(BaseModel):
377
+ name: str
378
+ mapping_config: Dict[str, str]
379
+
380
+
381
+ # ==========================================
382
+ # 6. FASTAPI APPLICATION
383
+ # ==========================================
384
+
385
+ app = FastAPI(title="Etsy Profit Manager Backend - Multi-Shop")
386
+
387
+ app.add_middleware(
388
+ CORSMiddleware,
389
+ allow_origins=["*"],
390
+ allow_credentials=True,
391
+ allow_methods=["*"],
392
+ allow_headers=["*"],
393
+ )
394
+
395
+
396
+ # ==========================================
397
+ # 7. AUTHENTICATION & USER MANAGEMENT
398
+ # ==========================================
399
+
400
+ @app.on_event("startup")
401
+ def create_initial_admin():
402
+ db = SessionLocal()
403
+ try:
404
+ if db.query(User).count() == 0:
405
+ admin_user = User(
406
+ username="admin",
407
+ hashed_password=get_password_hash("admin123"),
408
+ role="admin"
409
+ )
410
+ db.add(admin_user)
411
+ db.commit()
412
+ print("Default admin created: admin / admin123")
413
+ finally:
414
+ db.close()
415
+
416
+ @app.post("/token", response_model=Token)
417
+ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
418
+ user = db.query(User).filter(User.username == form_data.username).first()
419
+ if not user or not verify_password(form_data.password, user.hashed_password):
420
+ raise HTTPException(
421
+ status_code=status.HTTP_401_UNAUTHORIZED,
422
+ detail="Incorrect username or password",
423
+ headers={"WWW-Authenticate": "Bearer"},
424
+ )
425
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
426
+ access_token = create_access_token(
427
+ data={"sub": user.username}, expires_delta=access_token_expires
428
+ )
429
+ return {"access_token": access_token, "token_type": "bearer"}
430
+
431
+ @app.get("/users/me", response_model=UserResponse)
432
+ async def read_users_me(current_user: User = Depends(get_current_active_user)):
433
+ return current_user
434
+
435
+ @app.get("/admin/users", response_model=List[UserResponse])
436
+ async def read_users(db: Session = Depends(get_db), current_user: User = Depends(get_admin_user)):
437
+ users = db.query(User).all()
438
+ return users
439
+
440
+ @app.post("/admin/users", response_model=UserResponse)
441
+ async def create_user(user: UserCreate, db: Session = Depends(get_db), current_user: User = Depends(get_admin_user)):
442
+ db_user = db.query(User).filter(User.username == user.username).first()
443
+ if db_user:
444
+ raise HTTPException(status_code=400, detail="Username already registered")
445
+ hashed_password = get_password_hash(user.password)
446
+ new_user = User(
447
+ username=user.username,
448
+ hashed_password=hashed_password,
449
+ role=user.role,
450
+ permissions=user.permissions
451
+ )
452
+ db.add(new_user)
453
+ db.commit()
454
+ db.refresh(new_user)
455
+ return new_user
456
+
457
+ @app.put("/admin/users/{user_id}/permissions", response_model=UserResponse)
458
+ async def update_user_permissions(user_id: int, permissions: List[str], db: Session = Depends(get_db), current_user: User = Depends(get_admin_user)):
459
+ user = db.query(User).filter(User.id == user_id).first()
460
+ if not user:
461
+ raise HTTPException(status_code=404, detail="User not found")
462
+
463
+ # Don't allow removing permissions from super admin if checks were strict,
464
+ # but here we rely on role='admin' mostly.
465
+ # Still good practice not to mess with own permissions if not needed.
466
+
467
+ user.permissions = permissions
468
+ db.commit()
469
+ db.refresh(user)
470
+ return user
471
+
472
+ @app.delete("/admin/users/{user_id}")
473
+ async def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_admin_user)):
474
+ user = db.query(User).filter(User.id == user_id).first()
475
+ if not user:
476
+ raise HTTPException(status_code=404, detail="User not found")
477
+ if user.id == current_user.id:
478
+ raise HTTPException(status_code=400, detail="Cannot delete yourself")
479
+ db.delete(user)
480
+ db.commit()
481
+ return {"status": "success", "message": "User deleted"}
482
+
483
+
484
+ # ==========================================
485
+ # 8. SHOP MANAGEMENT ENDPOINTS
486
+ # ==========================================
487
+
488
+ @app.get("/")
489
+ def read_root():
490
+ return RedirectResponse(url="/docs")
491
+
492
+ @app.get("/api/shops")
493
+ def list_shops(db: Session = Depends(get_db)):
494
+ """List all shops"""
495
+ shops = db.query(Shop).options(subqueryload(Shop.orders), subqueryload(Shop.providers)).filter(Shop.is_active == True).order_by(Shop.created_at.desc()).all()
496
+ return [{
497
+ "id": s.id,
498
+ "name": s.name,
499
+ "slug": s.slug,
500
+ "description": s.description,
501
+ "created_at": s.created_at,
502
+ "is_active": s.is_active,
503
+ "order_count": len(s.orders),
504
+ "provider_count": len(s.providers)
505
+ } for s in shops]
506
+
507
+ @app.post("/api/shops")
508
+ def create_shop(
509
+ name: str = Form(...),
510
+ slug: str = Form(...),
511
+ description: str = Form(None),
512
+ db: Session = Depends(get_db)
513
+ ):
514
+ """Create a new shop"""
515
+ # Validate slug uniqueness
516
+ existing = db.query(Shop).filter(Shop.slug == slug).first()
517
+ if existing:
518
+ raise HTTPException(status_code=400, detail=f"Shop with slug '{slug}' already exists")
519
+
520
+ # Normalize slug
521
+ normalized_slug = re.sub(r'[^a-z0-9-]', '-', slug.lower().strip())
522
+
523
+ shop = Shop(
524
+ name=name,
525
+ slug=normalized_slug,
526
+ description=description
527
+ )
528
+ db.add(shop)
529
+ db.commit()
530
+ db.refresh(shop)
531
+
532
+ return {
533
+ "id": shop.id,
534
+ "name": shop.name,
535
+ "slug": shop.slug,
536
+ "description": shop.description,
537
+ "message": "Shop created successfully"
538
+ }
539
+
540
+ @app.get("/api/shops/{shop_id}")
541
+ def get_shop(shop_id: int, db: Session = Depends(get_db)):
542
+ """Get shop details"""
543
+ shop = get_shop_or_404(db, shop_id)
544
+ return {
545
+ "id": shop.id,
546
+ "name": shop.name,
547
+ "slug": shop.slug,
548
+ "description": shop.description,
549
+ "created_at": shop.created_at,
550
+ "is_active": shop.is_active,
551
+ "order_count": len(shop.orders),
552
+ "provider_count": len(shop.providers)
553
+ }
554
+
555
+ @app.put("/api/shops/{shop_id}")
556
+ def update_shop(
557
+ shop_id: int,
558
+ name: str = Form(None),
559
+ description: str = Form(None),
560
+ is_active: bool = Form(None),
561
+ db: Session = Depends(get_db)
562
+ ):
563
+ """Update shop details"""
564
+ shop = get_shop_or_404(db, shop_id)
565
+
566
+ if name is not None:
567
+ shop.name = name
568
+ if description is not None:
569
+ shop.description = description
570
+ if is_active is not None:
571
+ shop.is_active = is_active
572
+
573
+ db.commit()
574
+ return {"status": "success", "message": "Shop updated"}
575
+
576
+ @app.delete("/api/shops/{shop_id}")
577
+ def delete_shop(shop_id: int, db: Session = Depends(get_db)):
578
+ """Delete shop and ALL its data (CASCADE)"""
579
+ shop = get_shop_or_404(db, shop_id)
580
+
581
+ db.delete(shop)
582
+ db.commit()
583
+
584
+ return {"status": "success", "message": f"Shop '{shop.name}' and all its data deleted"}
585
+
586
+ @app.post("/api/shops/{shop_id}/reset")
587
+ def reset_shop_data(shop_id: int, db: Session = Depends(get_db)):
588
+ """Reset all data for a specific shop (keep shop itself)"""
589
+ shop = get_shop_or_404(db, shop_id)
590
+
591
+ # Delete in order respecting FK constraints
592
+ db.query(FulfillmentRecord).filter(FulfillmentRecord.shop_id == shop_id).delete()
593
+ db.query(EtsyOrder).filter(EtsyOrder.shop_id == shop_id).delete()
594
+ db.query(ImportHistory).filter(ImportHistory.shop_id == shop_id).delete()
595
+ db.query(FulfillmentProvider).filter(FulfillmentProvider.shop_id == shop_id).delete()
596
+ db.commit()
597
+
598
+ return {"status": "success", "message": f"All data for shop '{shop.name}' has been reset"}
599
+
600
+
601
+ # ==========================================
602
+ # 8. SHOP-SCOPED ORDER ENDPOINTS
603
+ # ==========================================
604
+
605
+ @app.get("/api/shops/{shop_id}/orders")
606
+ def get_orders(shop_id: int, db: Session = Depends(get_db)):
607
+ """Get all orders for a specific shop"""
608
+ get_shop_or_404(db, shop_id)
609
+
610
+ orders = db.query(EtsyOrder).options(joinedload(EtsyOrder.fulfillment_records)).filter(EtsyOrder.shop_id == shop_id).all()
611
+ result = []
612
+ for o in orders:
613
+ status = "Pending"
614
+ fulfillment_cost = 0.0
615
+ match_info = []
616
+
617
+ if o.fulfillment_records:
618
+ status = "Shipped"
619
+ for rec in o.fulfillment_records:
620
+ fulfillment_cost += rec.total_cost
621
+ if rec.match_method:
622
+ match_info.append(rec.match_method)
623
+
624
+ result.append({
625
+ "id": o.order_id,
626
+ "internal_id": o.id,
627
+ "date": o.sale_date,
628
+ "customer": o.full_name,
629
+ "items": o.skus,
630
+ "total": o.order_total,
631
+ "status": status,
632
+ "fulfillment_cost": fulfillment_cost,
633
+ "match_info": match_info
634
+ })
635
+ return result
636
+
637
+
638
+ # ==========================================
639
+ # 9. SHOP-SCOPED PROVIDER ENDPOINTS
640
+ # ==========================================
641
+
642
+ @app.get("/api/shops/{shop_id}/providers")
643
+ def get_providers(shop_id: int, db: Session = Depends(get_db)):
644
+ """Get all providers for a specific shop"""
645
+ get_shop_or_404(db, shop_id)
646
+
647
+ providers = db.query(FulfillmentProvider).filter(FulfillmentProvider.shop_id == shop_id).all()
648
+ return [{"id": p.id, "name": p.name, "mapping_config": p.mapping_config} for p in providers]
649
+
650
+ @app.post("/api/shops/{shop_id}/providers")
651
+ def create_provider(
652
+ shop_id: int,
653
+ name: str = Form(...),
654
+ mapping_config: str = Form(...),
655
+ db: Session = Depends(get_db)
656
+ ):
657
+ """Create or update a provider for a specific shop"""
658
+ get_shop_or_404(db, shop_id)
659
+
660
+ try:
661
+ config_dict = json.loads(mapping_config)
662
+ except json.JSONDecodeError:
663
+ raise HTTPException(status_code=400, detail="Invalid JSON for mapping_config")
664
+
665
+ # Check if provider exists for THIS shop
666
+ provider = db.query(FulfillmentProvider).filter(
667
+ FulfillmentProvider.shop_id == shop_id,
668
+ FulfillmentProvider.name == name
669
+ ).first()
670
+
671
+ if not provider:
672
+ provider = FulfillmentProvider(
673
+ shop_id=shop_id,
674
+ name=name,
675
+ mapping_config=config_dict
676
+ )
677
+ db.add(provider)
678
+ else:
679
+ provider.mapping_config = config_dict
680
+
681
+ db.commit()
682
+ db.refresh(provider)
683
+ return {"status": "success", "provider_id": provider.id}
684
+
685
+
686
+ # ==========================================
687
+ # 10. SHOP-SCOPED UPLOAD ENDPOINTS
688
+ # ==========================================
689
+
690
+ @app.post("/api/shops/{shop_id}/upload/etsy")
691
+ async def upload_etsy(shop_id: int, file: UploadFile = File(...), db: Session = Depends(get_db)):
692
+ """Upload Etsy orders CSV for a specific shop"""
693
+ get_shop_or_404(db, shop_id)
694
+
695
+ contents = await file.read()
696
+ content_str = contents.decode('utf-8')
697
+ df = pd.read_csv(io.StringIO(content_str))
698
+ df.columns = [c.strip() for c in df.columns]
699
+
700
+ # Create History Record for THIS shop
701
+ history = ImportHistory(
702
+ shop_id=shop_id,
703
+ import_type="etsy",
704
+ file_name=file.filename,
705
+ raw_content=content_str,
706
+ record_count=0
707
+ )
708
+ db.add(history)
709
+ db.commit()
710
+ db.refresh(history)
711
+
712
+ count = 0
713
+ for _, row in df.iterrows():
714
+ order_id = str(row.get('Order ID', ''))
715
+
716
+ # Check if order exists for THIS shop only
717
+ existing_order = db.query(EtsyOrder).filter(
718
+ EtsyOrder.shop_id == shop_id,
719
+ EtsyOrder.order_id == order_id
720
+ ).first()
721
+
722
+ order_data = {
723
+ "shop_id": shop_id,
724
+ "order_id": order_id,
725
+ "sale_date": pd.to_datetime(row.get('Sale Date')).date(),
726
+ "full_name": clean_value(row.get('Full Name')),
727
+ "first_name": clean_value(row.get('First Name')),
728
+ "last_name": clean_value(row.get('Last Name')),
729
+ "buyer_user_id": safe_str(row.get('Buyer User ID'), None),
730
+ "street_1": clean_value(row.get('Street 1')),
731
+ "street_2": clean_value(row.get('Street 2')),
732
+ "ship_city": clean_value(row.get('Ship City')),
733
+ "ship_state": clean_value(row.get('Ship State')),
734
+ "ship_zipcode": safe_str(row.get('Ship Zipcode', ''), ''),
735
+ "ship_country": clean_value(row.get('Ship Country')),
736
+ "currency": safe_str(row.get('Currency', 'USD'), 'USD'),
737
+ "order_value": clean_currency(row.get('Order Value')),
738
+ "order_total": clean_currency(row.get('Order Total')),
739
+ "order_net": clean_currency(row.get('Order Net')),
740
+ "shipping_revenue": clean_currency(row.get('Shipping')),
741
+ "skus": clean_value(row.get('SKU')),
742
+ "import_id": history.id
743
+ }
744
+
745
+ if existing_order:
746
+ for key, value in order_data.items():
747
+ setattr(existing_order, key, value)
748
+ else:
749
+ new_order = EtsyOrder(**order_data)
750
+ db.add(new_order)
751
+ count += 1
752
+
753
+ history.record_count = count
754
+ db.commit()
755
+ return {"message": f"Successfully processed {count} Etsy orders for shop"}
756
+
757
+ @app.post("/api/shops/{shop_id}/upload/fulfillment")
758
+ async def upload_fulfillment(
759
+ shop_id: int,
760
+ provider_id: int = Form(...),
761
+ file: UploadFile = File(...),
762
+ db: Session = Depends(get_db)
763
+ ):
764
+ """Upload fulfillment CSV for a specific shop"""
765
+ get_shop_or_404(db, shop_id)
766
+
767
+ # Ensure provider belongs to this shop
768
+ provider = db.query(FulfillmentProvider).filter(
769
+ FulfillmentProvider.id == provider_id,
770
+ FulfillmentProvider.shop_id == shop_id
771
+ ).first()
772
+ if not provider:
773
+ raise HTTPException(status_code=404, detail="Provider not found for this shop")
774
+
775
+ mapping = provider.mapping_config
776
+ contents = await file.read()
777
+ content_str = contents.decode('utf-8')
778
+ df = pd.read_csv(io.StringIO(content_str))
779
+
780
+ # Create History Record
781
+ history = ImportHistory(
782
+ shop_id=shop_id,
783
+ import_type="fulfillment",
784
+ file_name=file.filename,
785
+ raw_content=content_str,
786
+ record_count=0,
787
+ provider_id=provider.id
788
+ )
789
+ db.add(history)
790
+ db.commit()
791
+ db.refresh(history)
792
+
793
+ count = 0
794
+
795
+ ref_id_col = mapping.get("ref_id_col")
796
+ cost_col = mapping.get("total_cost_col")
797
+ tracking_col = mapping.get("tracking_col")
798
+
799
+ for _, row in df.iterrows():
800
+ raw_data = row.where(pd.notnull(row), None).to_dict()
801
+
802
+ ref_val = ""
803
+ if ref_id_col and ref_id_col in row:
804
+ ref_val = str(row[ref_id_col])
805
+
806
+ cost_val = 0.0
807
+ if cost_col and cost_col in row:
808
+ cost_val = clean_currency(row[cost_col])
809
+
810
+ track_val = ""
811
+ if tracking_col and tracking_col in row:
812
+ track_val = str(row[tracking_col])
813
+
814
+ record = FulfillmentRecord(
815
+ shop_id=shop_id,
816
+ provider_id=provider.id,
817
+ ref_id_value=ref_val,
818
+ total_cost=cost_val,
819
+ tracking_number=track_val,
820
+ raw_data=raw_data,
821
+ is_matched=False,
822
+ import_id=history.id
823
+ )
824
+ db.add(record)
825
+ count += 1
826
+
827
+ history.record_count = count
828
+ db.commit()
829
+
830
+ # Run matching for THIS shop only
831
+ match_result = run_matching_algorithm(db, shop_id)
832
+
833
+ return {
834
+ "message": f"Imported {count} records",
835
+ "matches_found": match_result
836
+ }
837
+
838
+
839
+ # ==========================================
840
+ # 11. SHOP-SCOPED IMPORT HISTORY ENDPOINTS
841
+ # ==========================================
842
+
843
+ @app.get("/api/shops/{shop_id}/imports")
844
+ def get_imports(
845
+ shop_id: int,
846
+ import_type: Optional[str] = None,
847
+ provider_id: Optional[int] = None,
848
+ db: Session = Depends(get_db)
849
+ ):
850
+ """Get import history for a specific shop"""
851
+ get_shop_or_404(db, shop_id)
852
+
853
+ query = db.query(ImportHistory).filter(ImportHistory.shop_id == shop_id)
854
+ if import_type:
855
+ query = query.filter(ImportHistory.import_type == import_type)
856
+ if provider_id:
857
+ query = query.filter(ImportHistory.provider_id == provider_id)
858
+
859
+ records = query.order_by(ImportHistory.upload_date.desc()).all()
860
+ return [{
861
+ "id": r.id,
862
+ "date": r.upload_date,
863
+ "file_name": r.file_name,
864
+ "count": r.record_count,
865
+ "provider_id": r.provider_id
866
+ } for r in records]
867
+
868
+ @app.get("/api/shops/{shop_id}/imports/{import_id}")
869
+ def get_import_details(shop_id: int, import_id: int, db: Session = Depends(get_db)):
870
+ """Get import details for a specific shop"""
871
+ get_shop_or_404(db, shop_id)
872
+
873
+ record = db.query(ImportHistory).filter(
874
+ ImportHistory.id == import_id,
875
+ ImportHistory.shop_id == shop_id
876
+ ).first()
877
+ if not record:
878
+ raise HTTPException(status_code=404, detail="Import not found for this shop")
879
+ return {
880
+ "id": record.id,
881
+ "file_name": record.file_name,
882
+ "raw_content": record.raw_content
883
+ }
884
+
885
+ @app.delete("/api/shops/{shop_id}/imports/{import_id}")
886
+ def delete_import(shop_id: int, import_id: int, db: Session = Depends(get_db)):
887
+ """Delete import for a specific shop"""
888
+ get_shop_or_404(db, shop_id)
889
+
890
+ record = db.query(ImportHistory).filter(
891
+ ImportHistory.id == import_id,
892
+ ImportHistory.shop_id == shop_id
893
+ ).first()
894
+ if not record:
895
+ raise HTTPException(status_code=404, detail="Import not found for this shop")
896
+
897
+ db.delete(record)
898
+ db.commit()
899
+ return {"status": "success", "message": "Import deleted"}
900
+
901
+
902
+ # ==========================================
903
+ # 12. SHOP-SCOPED ANALYTICS ENDPOINTS
904
+ # ==========================================
905
+
906
+ @app.get("/api/shops/{shop_id}/analytics/summary")
907
+ def get_shop_summary(
908
+ shop_id: int,
909
+ start_date: Optional[str] = None,
910
+ end_date: Optional[str] = None,
911
+ db: Session = Depends(get_db)
912
+ ):
913
+ """Get comprehensive summary for a specific shop with comparison to previous period"""
914
+ from datetime import timedelta
915
+
916
+ get_shop_or_404(db, shop_id)
917
+
918
+ # Parse dates
919
+ current_start = None
920
+ current_end = None
921
+ if start_date:
922
+ current_start = datetime.strptime(start_date, "%Y-%m-%d").date()
923
+ if end_date:
924
+ current_end = datetime.strptime(end_date, "%Y-%m-%d").date()
925
+
926
+ # Calculate previous period dates
927
+ prev_start = None
928
+ prev_end = None
929
+ if current_start and current_end:
930
+ duration = (current_end - current_start).days + 1
931
+ prev_end = current_start - timedelta(days=1)
932
+ prev_start = prev_end - timedelta(days=duration - 1)
933
+
934
+ def calculate_period_stats(start_dt, end_dt):
935
+ """Calculate stats for a date range using efficient querying"""
936
+ # Base query with EAGER LOADING of fulfillment records to prevent N+1
937
+ query = db.query(EtsyOrder).options(joinedload(EtsyOrder.fulfillment_records)).filter(EtsyOrder.shop_id == shop_id)
938
+
939
+ if start_dt and end_dt:
940
+ query = query.filter(EtsyOrder.sale_date >= start_dt, EtsyOrder.sale_date <= end_dt)
941
+
942
+ orders_list = query.all()
943
+
944
+ total_revenue = sum(o.order_total for o in orders_list)
945
+ total_net = sum(o.order_net for o in orders_list)
946
+ total_cost = 0
947
+ fulfilled_count = 0
948
+
949
+ for order in orders_list:
950
+ if order.fulfillment_records:
951
+ fulfilled_count += 1
952
+ total_cost += sum(r.total_cost for r in order.fulfillment_records)
953
+
954
+ total_profit = total_net - total_cost
955
+ margin = (total_profit / total_revenue * 100) if total_revenue > 0 else 0
956
+ fulfillment_rate = (fulfilled_count / len(orders_list) * 100) if len(orders_list) > 0 else 0
957
+ avg_order_value = total_revenue / len(orders_list) if len(orders_list) > 0 else 0
958
+
959
+ return {
960
+ "total_revenue": round(total_revenue, 2),
961
+ "total_cost": round(total_cost, 2),
962
+ "total_profit": round(total_profit, 2),
963
+ "total_orders": len(orders_list),
964
+ "total_fulfilled": fulfilled_count,
965
+ "profit_margin": round(margin, 1),
966
+ "fulfillment_rate": round(fulfillment_rate, 1),
967
+ "avg_order_value": round(avg_order_value, 2)
968
+ }
969
+
970
+ # Calculate current period stats
971
+ current_stats = calculate_period_stats(current_start, current_end)
972
+
973
+ # Calculate previous period stats
974
+ prev_stats = None
975
+ if prev_start and prev_end:
976
+ prev_stats = calculate_period_stats(prev_start, prev_end)
977
+
978
+ # Calculate percentage changes
979
+ def calc_change(current, previous):
980
+ if previous and previous > 0:
981
+ return round(((current - previous) / previous) * 100, 1)
982
+ return None
983
+
984
+ changes = {}
985
+ if prev_stats:
986
+ changes = {
987
+ "revenue_change": calc_change(current_stats["total_revenue"], prev_stats["total_revenue"]),
988
+ "cost_change": calc_change(current_stats["total_cost"], prev_stats["total_cost"]),
989
+ "profit_change": calc_change(current_stats["total_profit"], prev_stats["total_profit"]),
990
+ "orders_change": calc_change(current_stats["total_orders"], prev_stats["total_orders"]),
991
+ "fulfillment_rate_change": calc_change(current_stats["fulfillment_rate"], prev_stats["fulfillment_rate"])
992
+ }
993
+
994
+ shop = db.query(Shop).filter(Shop.id == shop_id).first()
995
+
996
+ return {
997
+ "shop": {
998
+ "id": shop.id,
999
+ "name": shop.name,
1000
+ "slug": shop.slug
1001
+ },
1002
+ "aggregates": current_stats,
1003
+ "changes": changes,
1004
+ "previous_period": prev_stats
1005
+ }
1006
+
1007
+ @app.get("/api/shops/{shop_id}/analytics/net-profit")
1008
+ def get_profit_data(
1009
+ shop_id: int,
1010
+ start_date: Optional[str] = None,
1011
+ end_date: Optional[str] = None,
1012
+ db: Session = Depends(get_db)
1013
+ ):
1014
+ """Get profit analytics for a specific shop"""
1015
+ get_shop_or_404(db, shop_id)
1016
+
1017
+ # OPTIMIZATION: Use joinedload to fetch fulfillment records in the SAME query
1018
+ query = db.query(EtsyOrder).options(joinedload(EtsyOrder.fulfillment_records)).filter(EtsyOrder.shop_id == shop_id)
1019
+
1020
+ # OPTIMIZATION: Filter by date at database level if provided
1021
+ if start_date:
1022
+ query = query.filter(EtsyOrder.sale_date >= start_date)
1023
+ if end_date:
1024
+ query = query.filter(EtsyOrder.sale_date <= end_date)
1025
+
1026
+ results = query.all()
1027
+
1028
+ data = []
1029
+ for order in results:
1030
+ fulfillment_recs = order.fulfillment_records
1031
+ total_fulfillment_cost = sum(rec.total_cost for rec in fulfillment_recs)
1032
+ net_profit = order.order_net - total_fulfillment_cost
1033
+
1034
+ status = "Unfulfilled"
1035
+ match_info = []
1036
+ if fulfillment_recs:
1037
+ status = "Fulfilled"
1038
+ methods = set()
1039
+ for r in fulfillment_recs:
1040
+ if r.match_method:
1041
+ methods.add(r.match_method)
1042
+ match_info = list(methods)
1043
+
1044
+ data.append({
1045
+ "order_id": order.order_id,
1046
+ "internal_id": order.id,
1047
+ "sale_date": order.sale_date,
1048
+ "customer": order.full_name,
1049
+ "revenue": order.order_total,
1050
+ "etsy_net": order.order_net,
1051
+ "cost": total_fulfillment_cost,
1052
+ "profit": net_profit,
1053
+ "margin": (net_profit / order.order_total * 100) if order.order_total else 0,
1054
+ "status": status,
1055
+ "match_info": match_info
1056
+ })
1057
+ return data
1058
+
1059
+ @app.post("/api/shops/{shop_id}/actions/run-matching")
1060
+ def trigger_manual_matching(shop_id: int, db: Session = Depends(get_db)):
1061
+ """Manually re-run matching for a specific shop"""
1062
+ get_shop_or_404(db, shop_id)
1063
+ count = run_matching_algorithm(db, shop_id)
1064
+ return {"message": f"Matching completed. Found {count} new matches."}
1065
+
1066
+
1067
+
1068
+ # ==========================================
1069
+ # 13. CROSS-SHOP ANALYTICS
1070
+ # ==========================================
1071
+
1072
+ @app.get("/api/analytics/all-shops/summary")
1073
+ def get_all_shops_summary(
1074
+ start_date: Optional[str] = None,
1075
+ end_date: Optional[str] = None,
1076
+ db: Session = Depends(get_db)
1077
+ ):
1078
+ """Get comprehensive summary across all shops with aggregates and comparison to previous period"""
1079
+ from datetime import timedelta
1080
+
1081
+ # Parse dates
1082
+ current_start = None
1083
+ current_end = None
1084
+ if start_date:
1085
+ current_start = datetime.strptime(start_date, "%Y-%m-%d").date()
1086
+ if end_date:
1087
+ current_end = datetime.strptime(end_date, "%Y-%m-%d").date()
1088
+
1089
+ # Calculate previous period dates_
1090
+ prev_start = None
1091
+ prev_end = None
1092
+ if current_start and current_end:
1093
+ duration = (current_end - current_start).days + 1
1094
+ prev_end = current_start - timedelta(days=1)
1095
+ prev_start = prev_end - timedelta(days=duration - 1)
1096
+
1097
+ # Get all active shops for mapping
1098
+ active_shops = db.query(Shop).filter(Shop.is_active == True).all()
1099
+ active_shop_ids = [s.id for s in active_shops]
1100
+ shop_map = {s.id: s for s in active_shops}
1101
+
1102
+ def calculate_period_stats(start_dt, end_dt):
1103
+ """Calculate stats for a specific period using efficient querying"""
1104
+ # OPTIMIZATION: Query ORDERS directly with joinedload, instead of looping shops
1105
+ query = db.query(EtsyOrder).options(joinedload(EtsyOrder.fulfillment_records)).filter(EtsyOrder.shop_id.in_(active_shop_ids))
1106
+
1107
+ if start_dt and end_dt:
1108
+ query = query.filter(EtsyOrder.sale_date >= start_dt, EtsyOrder.sale_date <= end_dt)
1109
+
1110
+ all_orders = query.all()
1111
+
1112
+ # Group by shop in Python (in-memory aggregation is fast enough for <10k items, better than N+1 queries)
1113
+ shop_stats_map = {}
1114
+
1115
+ # Initialize map
1116
+ for s_id in active_shop_ids:
1117
+ shop_stats_map[s_id] = {
1118
+ "revenue": 0, "net": 0, "cost": 0, "orders": 0, "fulfilled_count": 0
1119
+ }
1120
+
1121
+ grand_totals = {
1122
+ "total_revenue": 0, "total_cost": 0, "total_profit": 0, "total_orders": 0, "total_fulfilled": 0
1123
+ }
1124
+
1125
+ for order in all_orders:
1126
+ s_id = order.shop_id
1127
+ if s_id not in shop_stats_map: continue
1128
+
1129
+ stats = shop_stats_map[s_id]
1130
+
1131
+ stats["revenue"] += order.order_total
1132
+ stats["net"] += order.order_net
1133
+ stats["orders"] += 1
1134
+
1135
+ order_cost = 0
1136
+ if order.fulfillment_records:
1137
+ stats["fulfilled_count"] += 1
1138
+ order_cost = sum(r.total_cost for r in order.fulfillment_records)
1139
+ stats["cost"] += order_cost
1140
+
1141
+ # Grand totals
1142
+ grand_totals["total_revenue"] += order.order_total
1143
+ grand_totals["total_cost"] += order_cost
1144
+ grand_totals["total_profit"] += (order.order_net - order_cost)
1145
+ grand_totals["total_orders"] += 1
1146
+ if order.fulfillment_records:
1147
+ grand_totals["total_fulfilled"] += 1
1148
+
1149
+ # Format shop summaries
1150
+ shop_summaries = []
1151
+ for s_id, stats in shop_stats_map.items():
1152
+ shop = shop_map.get(s_id)
1153
+ if not shop: continue
1154
+
1155
+ shop_profit = stats["net"] - stats["cost"]
1156
+ shop_margin = (shop_profit / stats["revenue"] * 100) if stats["revenue"] > 0 else 0
1157
+ fulfillment_rate = (stats["fulfilled_count"] / stats["orders"] * 100) if stats["orders"] > 0 else 0
1158
+ avg_order_value = stats["revenue"] / stats["orders"] if stats["orders"] > 0 else 0
1159
+
1160
+ shop_summaries.append({
1161
+ "shop_id": s_id,
1162
+ "shop_name": shop.name,
1163
+ "order_count": stats["orders"],
1164
+ "fulfilled_count": stats["fulfilled_count"],
1165
+ "fulfillment_rate": round(fulfillment_rate, 1),
1166
+ "total_revenue": round(stats["revenue"], 2),
1167
+ "total_cost": round(stats["cost"], 2),
1168
+ "total_profit": round(shop_profit, 2),
1169
+ "profit_margin": round(shop_margin, 1),
1170
+ "avg_order_value": round(avg_order_value, 2)
1171
+ })
1172
+
1173
+ overall_margin = (grand_totals["total_profit"] / grand_totals["total_revenue"] * 100) if grand_totals["total_revenue"] > 0 else 0
1174
+ overall_fulfillment_rate = (grand_totals["total_fulfilled"] / grand_totals["total_orders"] * 100) if grand_totals["total_orders"] > 0 else 0
1175
+
1176
+ return shop_summaries, grand_totals, overall_margin, overall_fulfillment_rate
1177
+
1178
+ # Calculate current period
1179
+ shop_summaries, grand_totals, overall_margin, overall_fulfillment_rate = calculate_period_stats(current_start, current_end)
1180
+
1181
+ # Calculate previous period for comparison
1182
+ prev_totals = None
1183
+ if prev_start and prev_end:
1184
+ _, prev_grand_totals, prev_margin, prev_fulfillment_rate = calculate_period_stats(prev_start, prev_end)
1185
+ prev_totals = {
1186
+ "total_revenue": prev_grand_totals["total_revenue"],
1187
+ "total_cost": prev_grand_totals["total_cost"],
1188
+ "total_profit": prev_grand_totals["total_profit"],
1189
+ "total_orders": prev_grand_totals["total_orders"],
1190
+ "total_fulfilled": prev_grand_totals["total_fulfilled"],
1191
+ "overall_margin": prev_margin,
1192
+ "overall_fulfillment_rate": prev_fulfillment_rate
1193
+ }
1194
+
1195
+ # Calculate percentage changes
1196
+ def calc_change(current, previous):
1197
+ if previous and previous > 0:
1198
+ return round(((current - previous) / previous) * 100, 1)
1199
+ return None
1200
+
1201
+ changes = {}
1202
+ if prev_totals:
1203
+ changes = {
1204
+ "revenue_change": calc_change(grand_totals["total_revenue"], prev_totals["total_revenue"]),
1205
+ "cost_change": calc_change(grand_totals["total_cost"], prev_totals["total_cost"]),
1206
+ "profit_change": calc_change(grand_totals["total_profit"], prev_totals["total_profit"]),
1207
+ "orders_change": calc_change(grand_totals["total_orders"], prev_totals["total_orders"]),
1208
+ "fulfillment_rate_change": calc_change(overall_fulfillment_rate, prev_totals["overall_fulfillment_rate"])
1209
+ }
1210
+
1211
+ # Sort shops by profit for ranking
1212
+ shop_summaries.sort(key=lambda x: x["total_profit"], reverse=True)
1213
+
1214
+ # Identify best/worst performers
1215
+ best_performer = shop_summaries[0] if shop_summaries else None
1216
+ worst_performer = shop_summaries[-1] if len(shop_summaries) > 1 else None
1217
+
1218
+ return {
1219
+ "shops": shop_summaries,
1220
+ "aggregates": {
1221
+ "total_revenue": round(grand_totals["total_revenue"], 2),
1222
+ "total_cost": round(grand_totals["total_cost"], 2),
1223
+ "total_profit": round(grand_totals["total_profit"], 2),
1224
+ "total_orders": grand_totals["total_orders"],
1225
+ "total_fulfilled": grand_totals["total_fulfilled"],
1226
+ "overall_margin": round(overall_margin, 1),
1227
+ "overall_fulfillment_rate": round(overall_fulfillment_rate, 1),
1228
+ "shop_count": len(active_shops)
1229
+ },
1230
+ "changes": changes,
1231
+ "previous_period": prev_totals,
1232
+ "best_performer": best_performer,
1233
+ "worst_performer": worst_performer
1234
+ }
1235
+
1236
+
1237
+ @app.get("/api/analytics/all-shops/orders")
1238
+ def get_all_shops_orders(db: Session = Depends(get_db)):
1239
+ """Get all orders from all active shops with shop info"""
1240
+ # OPTIMIZATION: Query EtsyOrder directly with joinedload
1241
+ orders = db.query(EtsyOrder).options(joinedload(EtsyOrder.fulfillment_records), joinedload(EtsyOrder.shop)).join(Shop).filter(Shop.is_active == True).all()
1242
+
1243
+ result = []
1244
+ for o in orders:
1245
+ status = "Pending"
1246
+ fulfillment_cost = 0.0
1247
+ match_info = []
1248
+
1249
+ if o.fulfillment_records:
1250
+ status = "Shipped"
1251
+ for rec in o.fulfillment_records:
1252
+ fulfillment_cost += rec.total_cost
1253
+ if rec.match_method:
1254
+ match_info.append(rec.match_method)
1255
+
1256
+ result.append({
1257
+ "id": o.order_id,
1258
+ "internal_id": o.id,
1259
+ "date": o.sale_date,
1260
+ "customer": o.full_name,
1261
+ "items": o.skus,
1262
+ "total": o.order_total,
1263
+ "status": status,
1264
+ "fulfillment_cost": fulfillment_cost,
1265
+ "match_info": match_info,
1266
+ "shop_id": o.shop_id,
1267
+ "shop_name": o.shop.name if o.shop else "Unknown"
1268
+ })
1269
+
1270
+ # Sort by date descending
1271
+ result.sort(key=lambda x: x["date"] if x["date"] else "", reverse=True)
1272
+ return result
1273
+
1274
+
1275
+ @app.get("/api/analytics/all-shops/profit")
1276
+ def get_all_shops_profit(db: Session = Depends(get_db)):
1277
+ """Get profit data from all active shops with shop info"""
1278
+ shops = db.query(Shop).filter(Shop.is_active == True).all()
1279
+
1280
+ result = []
1281
+ for shop in shops:
1282
+ orders = db.query(EtsyOrder).filter(EtsyOrder.shop_id == shop.id).all()
1283
+
1284
+ for order in orders:
1285
+ fulfillment_recs = order.fulfillment_records
1286
+ total_fulfillment_cost = sum(rec.total_cost for rec in fulfillment_recs)
1287
+ net_profit = order.order_net - total_fulfillment_cost
1288
+
1289
+ status = "Unfulfilled"
1290
+ match_info = []
1291
+ if fulfillment_recs:
1292
+ status = "Fulfilled"
1293
+ methods = set()
1294
+ for r in fulfillment_recs:
1295
+ if r.match_method:
1296
+ methods.add(r.match_method)
1297
+ match_info = list(methods)
1298
+
1299
+ result.append({
1300
+ "order_id": order.order_id,
1301
+ "internal_id": order.id,
1302
+ "sale_date": order.sale_date,
1303
+ "customer": order.full_name,
1304
+ "revenue": order.order_total,
1305
+ "etsy_net": order.order_net,
1306
+ "cost": total_fulfillment_cost,
1307
+ "profit": net_profit,
1308
+ "margin": (net_profit / order.order_total * 100) if order.order_total else 0,
1309
+ "status": status,
1310
+ "match_info": match_info,
1311
+ "shop_id": shop.id,
1312
+ "shop_name": shop.name
1313
+ })
1314
+
1315
+ # Sort by date descending
1316
+ result.sort(key=lambda x: str(x["sale_date"]) if x["sale_date"] else "", reverse=True)
1317
+ return result
1318
+
1319
+
1320
+ # ==========================================
1321
+ # 14. MATCHING ALGORITHM (SHOP-ISOLATED)
1322
+ # ==========================================
1323
+
1324
+ def run_matching_algorithm(db: Session, shop_id: int):
1325
+ """Run matching algorithm for a SPECIFIC shop only"""
1326
+
1327
+ # Get unmatched records for THIS shop only
1328
+ unmatched_records = db.query(FulfillmentRecord).filter(
1329
+ FulfillmentRecord.shop_id == shop_id,
1330
+ FulfillmentRecord.is_matched == False
1331
+ ).order_by(FulfillmentRecord.id.asc()).all()
1332
+
1333
+ match_count = 0
1334
+
1335
+ # Get providers for THIS shop
1336
+ providers = db.query(FulfillmentProvider).filter(
1337
+ FulfillmentProvider.shop_id == shop_id
1338
+ ).all()
1339
+ provider_map = {p.id: p for p in providers}
1340
+
1341
+ # Session tracking (prevents double-booking within this run)
1342
+ session_matched_ids = set()
1343
+
1344
+ for record in unmatched_records:
1345
+ provider = provider_map.get(record.provider_id)
1346
+ if not provider:
1347
+ continue
1348
+
1349
+ mapping = provider.mapping_config
1350
+ raw = record.raw_data
1351
+
1352
+ def get_mapped_val(key):
1353
+ col_name = mapping.get(key)
1354
+ if col_name and col_name in raw:
1355
+ return normalize_search_text(raw[col_name])
1356
+ return ""
1357
+
1358
+ prov_id_txt = get_mapped_val("ref_id_col")
1359
+ prov_fname_txt = get_mapped_val("first_name_col")
1360
+ prov_lname_txt = get_mapped_val("last_name_col")
1361
+ prov_addr_txt = get_mapped_val("address_col")
1362
+ prov_city_txt = get_mapped_val("city_col")
1363
+ prov_state_txt = get_mapped_val("state_col")
1364
+
1365
+ prov_name_context = f"{prov_fname_txt} {prov_lname_txt}"
1366
+ prov_addr_context = f"{prov_addr_txt} {prov_city_txt} {prov_state_txt}"
1367
+
1368
+ candidate_orders = []
1369
+
1370
+ # 1. ID Search - WITHIN THIS SHOP ONLY
1371
+ if prov_id_txt:
1372
+ potential_ids = re.findall(r'\d{9,}', prov_id_txt)
1373
+ for pid in potential_ids:
1374
+ order = db.query(EtsyOrder).filter(
1375
+ EtsyOrder.shop_id == shop_id, # CRITICAL: Shop isolation
1376
+ EtsyOrder.order_id == pid
1377
+ ).first()
1378
+ if order:
1379
+ candidate_orders.append(order)
1380
+
1381
+ # 2. Name Search - WITHIN THIS SHOP ONLY
1382
+ if len(prov_name_context) > 3:
1383
+ tokens = prov_name_context.split()
1384
+ for t in tokens:
1385
+ if len(t) > 3:
1386
+ candidates = db.query(EtsyOrder).filter(
1387
+ EtsyOrder.shop_id == shop_id, # CRITICAL: Shop isolation
1388
+ or_(
1389
+ EtsyOrder.first_name.ilike(f"%{t}%"),
1390
+ EtsyOrder.last_name.ilike(f"%{t}%")
1391
+ )
1392
+ ).limit(50).all()
1393
+ candidate_orders.extend(candidates)
1394
+ if len(candidate_orders) > 100:
1395
+ break
1396
+
1397
+ # Deduplicate
1398
+ unique_candidates = {}
1399
+ for c in candidate_orders:
1400
+ unique_candidates[c.id] = c # Use internal ID
1401
+ candidate_orders = list(unique_candidates.values())
1402
+
1403
+ # Exclude already matched in this session
1404
+ candidate_orders = [
1405
+ c for c in candidate_orders
1406
+ if c.id not in session_matched_ids
1407
+ ]
1408
+
1409
+ # Sort: unfulfilled first, then by date
1410
+ def sort_key(order):
1411
+ is_fulfilled = 1 if order.fulfillment_records else 0
1412
+ s_date = order.sale_date if order.sale_date else datetime.max.date()
1413
+ return (is_fulfilled, s_date)
1414
+
1415
+ candidate_orders.sort(key=sort_key)
1416
+
1417
+ # Verification
1418
+ final_match = None
1419
+ match_details = []
1420
+
1421
+ for cand in candidate_orders:
1422
+ verifications = []
1423
+
1424
+ etsy_id = str(cand.order_id)
1425
+ etsy_fname = normalize_search_text(cand.first_name)
1426
+ etsy_lname = normalize_search_text(cand.last_name)
1427
+ etsy_addr1 = normalize_search_text(cand.street_1)
1428
+ etsy_city = normalize_search_text(cand.ship_city)
1429
+ etsy_state = normalize_search_text(cand.ship_state)
1430
+
1431
+ # A. ID Verification
1432
+ if prov_id_txt and etsy_id in prov_id_txt:
1433
+ verifications.append("ID")
1434
+
1435
+ # B. Name Verification
1436
+ name_verified = False
1437
+ if etsy_fname and etsy_lname:
1438
+ if (etsy_fname in prov_name_context) and (etsy_lname in prov_name_context):
1439
+ name_verified = True
1440
+ verifications.append("Name")
1441
+
1442
+ # C. Address Verification
1443
+ addr_verified = False
1444
+ etsy_hn = extract_house_number(cand.street_1)
1445
+ prov_hn = extract_house_number(prov_addr_context)
1446
+
1447
+ hn_match = False
1448
+ if etsy_hn and prov_hn and etsy_hn == prov_hn:
1449
+ hn_match = True
1450
+
1451
+ city_in_context = (len(etsy_city) > 2) and (etsy_city in prov_addr_context)
1452
+ state_in_context = (len(etsy_state) >= 2) and (etsy_state in prov_addr_context)
1453
+
1454
+ street_name_only = etsy_addr1.replace(etsy_hn, "").strip() if etsy_hn else etsy_addr1
1455
+ street_in_context = (len(street_name_only) > 3) and (street_name_only in prov_addr_context)
1456
+
1457
+ if hn_match:
1458
+ if street_in_context or (city_in_context and state_in_context):
1459
+ addr_verified = True
1460
+ verifications.append("Address")
1461
+ elif street_in_context and city_in_context:
1462
+ addr_verified = True
1463
+ verifications.append("Address(NoHN)")
1464
+
1465
+ # Final Decision
1466
+ is_valid = False
1467
+ if "ID" in str(verifications):
1468
+ is_valid = True
1469
+ elif name_verified and addr_verified:
1470
+ is_valid = True
1471
+
1472
+ if is_valid:
1473
+ final_match = cand
1474
+ match_details = verifications
1475
+ session_matched_ids.add(cand.id)
1476
+ break
1477
+
1478
+ if final_match:
1479
+ record.etsy_order_id = final_match.id # Use internal ID
1480
+ record.is_matched = True
1481
+ record.match_method = f"{', '.join(match_details)}"
1482
+ match_count += 1
1483
+
1484
+ db.commit()
1485
+ return match_count
1486
+
1487
+
1488
+ # ==========================================
1489
+ # 15. LEGACY ENDPOINTS (For backward compatibility)
1490
+ # ==========================================
1491
+ # These can be removed once frontend is fully migrated
1492
+
1493
+ @app.get("/api/orders")
1494
+ def get_orders_legacy(db: Session = Depends(get_db)):
1495
+ """Legacy: Get all orders (returns empty if no default shop)"""
1496
+ return {"warning": "Please use /api/shops/{shop_id}/orders", "data": []}
1497
+
1498
+ @app.get("/api/providers")
1499
+ def get_providers_legacy(db: Session = Depends(get_db)):
1500
+ """Legacy: Get all providers"""
1501
+ return {"warning": "Please use /api/shops/{shop_id}/providers", "data": []}
1502
+
1503
+ @app.post("/api/admin/reset-database")
1504
+ def reset_database_legacy(db: Session = Depends(get_db)):
1505
+ """Legacy: Reset entire database"""
1506
+ try:
1507
+ db.query(FulfillmentRecord).delete()
1508
+ db.query(EtsyOrder).delete()
1509
+ db.query(ImportHistory).delete()
1510
+ db.query(FulfillmentProvider).delete()
1511
+ db.query(Shop).delete()
1512
+ db.commit()
1513
+ return {"status": "success", "message": "Database has been reset completely."}
1514
+ except Exception as e:
1515
+ db.rollback()
1516
+ raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ sqlalchemy
5
+ psycopg2-binary
6
+ pandas
7
+ python-jose[cryptography]
8
+ passlib[argon2]
9
+ python-dotenv
10
+ pydantic
11
+ numpy
12
+ requests