frontend-systems 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/.github/workflows/deploy.yml +15 -0
- package/Dockerfile +13 -0
- package/README.md +113 -0
- package/docker-compose.yml +9 -0
- package/next.config.js +52 -0
- package/package.json +38 -0
- package/postcss.config.js +6 -0
- package/src/app/dashboard/layout.tsx +23 -0
- package/src/app/dashboard/page.tsx +97 -0
- package/src/app/globals.css +149 -0
- package/src/app/layout.tsx +40 -0
- package/src/app/page.tsx +107 -0
- package/src/components/layouts/Header.tsx +46 -0
- package/src/components/layouts/Sidebar.tsx +81 -0
- package/src/components/ui/StatsCard.tsx +43 -0
- package/src/lib/api.ts +249 -0
- package/src/lib/utils.ts +81 -0
- package/src/types/index.ts +105 -0
- package/tailwind.config.ts +90 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Bell, Search, Menu } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export function Header() {
|
|
6
|
+
return (
|
|
7
|
+
<header className="flex h-16 items-center justify-between border-b border-dark-800 bg-dark-950 px-6">
|
|
8
|
+
{/* Left side */}
|
|
9
|
+
<div className="flex items-center">
|
|
10
|
+
<button className="rounded-lg p-2 text-dark-400 hover:bg-dark-800 hover:text-white lg:hidden">
|
|
11
|
+
<Menu className="h-5 w-5" />
|
|
12
|
+
</button>
|
|
13
|
+
|
|
14
|
+
{/* Search */}
|
|
15
|
+
<div className="ml-4 hidden lg:block">
|
|
16
|
+
<div className="relative">
|
|
17
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-500" />
|
|
18
|
+
<input
|
|
19
|
+
type="text"
|
|
20
|
+
placeholder="Search..."
|
|
21
|
+
className="input w-64 bg-dark-800 pl-10 text-white placeholder:text-dark-500 focus:ring-primary-500"
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
{/* Right side */}
|
|
28
|
+
<div className="flex items-center space-x-4">
|
|
29
|
+
{/* Notifications */}
|
|
30
|
+
<button className="relative rounded-lg p-2 text-dark-400 hover:bg-dark-800 hover:text-white">
|
|
31
|
+
<Bell className="h-5 w-5" />
|
|
32
|
+
<span className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary-500 text-[10px] font-medium text-white">
|
|
33
|
+
3
|
|
34
|
+
</span>
|
|
35
|
+
</button>
|
|
36
|
+
|
|
37
|
+
{/* User avatar (mobile) */}
|
|
38
|
+
<div className="lg:hidden">
|
|
39
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-500/20">
|
|
40
|
+
<span className="text-xs font-medium text-primary-400">AD</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</header>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { clsx } from 'clsx';
|
|
6
|
+
import {
|
|
7
|
+
LayoutDashboard,
|
|
8
|
+
Users,
|
|
9
|
+
ShoppingCart,
|
|
10
|
+
Package,
|
|
11
|
+
Settings,
|
|
12
|
+
BarChart3,
|
|
13
|
+
LogOut,
|
|
14
|
+
} from 'lucide-react';
|
|
15
|
+
|
|
16
|
+
const navigation = [
|
|
17
|
+
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
|
18
|
+
{ name: 'Users', href: '/dashboard/users', icon: Users },
|
|
19
|
+
{ name: 'Orders', href: '/dashboard/orders', icon: ShoppingCart },
|
|
20
|
+
{ name: 'Inventory', href: '/dashboard/inventory', icon: Package },
|
|
21
|
+
{ name: 'Analytics', href: '/dashboard/analytics', icon: BarChart3 },
|
|
22
|
+
{ name: 'Settings', href: '/dashboard/settings', icon: Settings },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function Sidebar() {
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex h-full w-64 flex-col border-r border-dark-800 bg-dark-950">
|
|
30
|
+
{/* Logo */}
|
|
31
|
+
<div className="flex h-16 items-center border-b border-dark-800 px-6">
|
|
32
|
+
<span className="text-xl font-bold text-white">
|
|
33
|
+
ERP<span className="text-primary-500">Dashboard</span>
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{/* Navigation */}
|
|
38
|
+
<nav className="flex-1 space-y-1 px-3 py-4">
|
|
39
|
+
{navigation.map((item) => {
|
|
40
|
+
const isActive = pathname === item.href;
|
|
41
|
+
return (
|
|
42
|
+
<Link
|
|
43
|
+
key={item.name}
|
|
44
|
+
href={item.href}
|
|
45
|
+
className={clsx(
|
|
46
|
+
'group flex items-center rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
|
47
|
+
isActive
|
|
48
|
+
? 'bg-primary-500/10 text-primary-400'
|
|
49
|
+
: 'text-dark-400 hover:bg-dark-800 hover:text-white'
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<item.icon
|
|
53
|
+
className={clsx(
|
|
54
|
+
'mr-3 h-5 w-5 flex-shrink-0 transition-colors',
|
|
55
|
+
isActive ? 'text-primary-400' : 'text-dark-500 group-hover:text-white'
|
|
56
|
+
)}
|
|
57
|
+
/>
|
|
58
|
+
{item.name}
|
|
59
|
+
</Link>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</nav>
|
|
63
|
+
|
|
64
|
+
{/* User Section */}
|
|
65
|
+
<div className="border-t border-dark-800 p-4">
|
|
66
|
+
<div className="flex items-center">
|
|
67
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-500/20">
|
|
68
|
+
<span className="text-sm font-medium text-primary-400">AD</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="ml-3 flex-1">
|
|
71
|
+
<p className="text-sm font-medium text-white">Admin User</p>
|
|
72
|
+
<p className="text-xs text-dark-500">admin@example.com</p>
|
|
73
|
+
</div>
|
|
74
|
+
<button className="rounded-lg p-2 text-dark-500 hover:bg-dark-800 hover:text-white">
|
|
75
|
+
<LogOut className="h-4 w-4" />
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { TrendingUp, TrendingDown } from 'lucide-react';
|
|
2
|
+
import { clsx } from 'clsx';
|
|
3
|
+
|
|
4
|
+
interface StatsCardProps {
|
|
5
|
+
title: string;
|
|
6
|
+
value: string;
|
|
7
|
+
change?: {
|
|
8
|
+
value: number;
|
|
9
|
+
trend: 'up' | 'down';
|
|
10
|
+
};
|
|
11
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function StatsCard({ title, value, change, icon: Icon }: StatsCardProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="card border-dark-700 p-6">
|
|
17
|
+
<div className="flex items-center justify-between">
|
|
18
|
+
<div className="rounded-lg bg-primary-500/10 p-3">
|
|
19
|
+
<Icon className="h-5 w-5 text-primary-400" />
|
|
20
|
+
</div>
|
|
21
|
+
{change && (
|
|
22
|
+
<div
|
|
23
|
+
className={clsx(
|
|
24
|
+
'flex items-center text-sm font-medium',
|
|
25
|
+
change.trend === 'up' ? 'text-success-500' : 'text-error-500'
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
{change.trend === 'up' ? (
|
|
29
|
+
<TrendingUp className="mr-1 h-4 w-4" />
|
|
30
|
+
) : (
|
|
31
|
+
<TrendingDown className="mr-1 h-4 w-4" />
|
|
32
|
+
)}
|
|
33
|
+
{change.value}%
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
<div className="mt-4">
|
|
38
|
+
<p className="text-sm font-medium text-dark-400">{title}</p>
|
|
39
|
+
<p className="mt-1 text-2xl font-bold text-white">{value}</p>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Client - Type-safe API wrapper for backend communication.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
|
6
|
+
|
|
7
|
+
interface RequestOptions {
|
|
8
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
9
|
+
body?: unknown;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ApiError {
|
|
14
|
+
detail: string;
|
|
15
|
+
type?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class ApiClient {
|
|
19
|
+
private baseUrl: string;
|
|
20
|
+
private token: string | null = null;
|
|
21
|
+
|
|
22
|
+
constructor(baseUrl: string) {
|
|
23
|
+
this.baseUrl = baseUrl;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setToken(token: string | null) {
|
|
27
|
+
this.token = token;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async request<T>(
|
|
31
|
+
endpoint: string,
|
|
32
|
+
options: RequestOptions = {}
|
|
33
|
+
): Promise<T> {
|
|
34
|
+
const { method = 'GET', body, headers = {} } = options;
|
|
35
|
+
|
|
36
|
+
const requestHeaders: Record<string, string> = {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
...headers,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (this.token) {
|
|
42
|
+
requestHeaders['Authorization'] = `Bearer ${this.token}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
46
|
+
method,
|
|
47
|
+
headers: requestHeaders,
|
|
48
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const error: ApiError = await response.json().catch(() => ({
|
|
53
|
+
detail: 'An unexpected error occurred',
|
|
54
|
+
}));
|
|
55
|
+
throw new Error(error.detail);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Handle 204 No Content
|
|
59
|
+
if (response.status === 204) {
|
|
60
|
+
return undefined as T;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return response.json();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Auth endpoints
|
|
67
|
+
async login(email: string, password: string) {
|
|
68
|
+
const formData = new URLSearchParams();
|
|
69
|
+
formData.append('username', email);
|
|
70
|
+
formData.append('password', password);
|
|
71
|
+
|
|
72
|
+
const response = await fetch(`${this.baseUrl}/api/v1/auth/login`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
75
|
+
body: formData,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error('Invalid credentials');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return response.json();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getCurrentUser() {
|
|
86
|
+
return this.request<User>('/api/v1/auth/me');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// User endpoints
|
|
90
|
+
async getUsers(skip = 0, limit = 50) {
|
|
91
|
+
return this.request<UserListResponse>(`/api/v1/users?skip=${skip}&limit=${limit}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getUser(id: string) {
|
|
95
|
+
return this.request<User>(`/api/v1/users/${id}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async createUser(data: CreateUserRequest) {
|
|
99
|
+
return this.request<User>('/api/v1/users', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: data,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async updateUser(id: string, data: UpdateUserRequest) {
|
|
106
|
+
return this.request<User>(`/api/v1/users/${id}`, {
|
|
107
|
+
method: 'PATCH',
|
|
108
|
+
body: data,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async deleteUser(id: string) {
|
|
113
|
+
return this.request<void>(`/api/v1/users/${id}`, {
|
|
114
|
+
method: 'DELETE',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Order endpoints
|
|
119
|
+
async getOrders(skip = 0, limit = 50, status?: string) {
|
|
120
|
+
let url = `/api/v1/orders?skip=${skip}&limit=${limit}`;
|
|
121
|
+
if (status) url += `&status=${status}`;
|
|
122
|
+
return this.request<OrderListResponse>(url);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getOrder(id: string) {
|
|
126
|
+
return this.request<Order>(`/api/v1/orders/${id}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async createOrder(data: CreateOrderRequest) {
|
|
130
|
+
return this.request<Order>('/api/v1/orders', {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
body: data,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Inventory endpoints
|
|
137
|
+
async getInventory(skip = 0, limit = 50, search?: string) {
|
|
138
|
+
let url = `/api/v1/inventory?skip=${skip}&limit=${limit}`;
|
|
139
|
+
if (search) url += `&search=${encodeURIComponent(search)}`;
|
|
140
|
+
return this.request<InventoryListResponse>(url);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async getInventoryItem(id: string) {
|
|
144
|
+
return this.request<InventoryItem>(`/api/v1/inventory/${id}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getLowStockItems() {
|
|
148
|
+
return this.request<InventoryItem[]>('/api/v1/inventory/low-stock');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Health check
|
|
152
|
+
async healthCheck() {
|
|
153
|
+
return this.request<HealthResponse>('/health');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Types
|
|
158
|
+
export interface User {
|
|
159
|
+
id: string;
|
|
160
|
+
email: string;
|
|
161
|
+
full_name: string;
|
|
162
|
+
role: string;
|
|
163
|
+
is_active: boolean;
|
|
164
|
+
is_verified: boolean;
|
|
165
|
+
created_at: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface UserListResponse {
|
|
169
|
+
items: User[];
|
|
170
|
+
total: number;
|
|
171
|
+
skip: number;
|
|
172
|
+
limit: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface CreateUserRequest {
|
|
176
|
+
email: string;
|
|
177
|
+
password: string;
|
|
178
|
+
full_name: string;
|
|
179
|
+
role?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface UpdateUserRequest {
|
|
183
|
+
full_name?: string;
|
|
184
|
+
role?: string;
|
|
185
|
+
is_active?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface Order {
|
|
189
|
+
id: string;
|
|
190
|
+
order_number: string;
|
|
191
|
+
customer_name: string;
|
|
192
|
+
customer_email: string | null;
|
|
193
|
+
status: string;
|
|
194
|
+
items: OrderItem[];
|
|
195
|
+
subtotal: number;
|
|
196
|
+
grand_total: number;
|
|
197
|
+
currency: string;
|
|
198
|
+
created_at: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface OrderItem {
|
|
202
|
+
id: string;
|
|
203
|
+
product_name: string;
|
|
204
|
+
quantity: number;
|
|
205
|
+
unit_price: number;
|
|
206
|
+
total: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface OrderListResponse {
|
|
210
|
+
items: Order[];
|
|
211
|
+
total: number;
|
|
212
|
+
skip: number;
|
|
213
|
+
limit: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface CreateOrderRequest {
|
|
217
|
+
customer_name: string;
|
|
218
|
+
customer_email?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface InventoryItem {
|
|
222
|
+
id: string;
|
|
223
|
+
sku: string;
|
|
224
|
+
name: string;
|
|
225
|
+
description: string | null;
|
|
226
|
+
category: string | null;
|
|
227
|
+
selling_price: number;
|
|
228
|
+
quantity_on_hand: number;
|
|
229
|
+
quantity_available: number;
|
|
230
|
+
needs_reorder: boolean;
|
|
231
|
+
is_active: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface InventoryListResponse {
|
|
235
|
+
items: InventoryItem[];
|
|
236
|
+
total: number;
|
|
237
|
+
skip: number;
|
|
238
|
+
limit: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface HealthResponse {
|
|
242
|
+
status: string;
|
|
243
|
+
timestamp: string;
|
|
244
|
+
environment: string;
|
|
245
|
+
version: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Singleton instance
|
|
249
|
+
export const apiClient = new ApiClient(API_URL);
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for className merging.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { type ClassValue, clsx } from 'clsx';
|
|
6
|
+
import { twMerge } from 'tailwind-merge';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Merge Tailwind CSS classes with clsx.
|
|
10
|
+
* Handles conflicting classes properly.
|
|
11
|
+
*/
|
|
12
|
+
export function cn(...inputs: ClassValue[]) {
|
|
13
|
+
return twMerge(clsx(inputs));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a number as currency.
|
|
18
|
+
*/
|
|
19
|
+
export function formatCurrency(
|
|
20
|
+
amount: number,
|
|
21
|
+
currency = 'USD',
|
|
22
|
+
locale = 'en-US'
|
|
23
|
+
): string {
|
|
24
|
+
return new Intl.NumberFormat(locale, {
|
|
25
|
+
style: 'currency',
|
|
26
|
+
currency,
|
|
27
|
+
}).format(amount);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format a date string.
|
|
32
|
+
*/
|
|
33
|
+
export function formatDate(
|
|
34
|
+
dateString: string,
|
|
35
|
+
options: Intl.DateTimeFormatOptions = {
|
|
36
|
+
year: 'numeric',
|
|
37
|
+
month: 'short',
|
|
38
|
+
day: 'numeric',
|
|
39
|
+
}
|
|
40
|
+
): string {
|
|
41
|
+
return new Date(dateString).toLocaleDateString('en-US', options);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format a date as relative time (e.g., "2 hours ago").
|
|
46
|
+
*/
|
|
47
|
+
export function formatRelativeTime(dateString: string): string {
|
|
48
|
+
const date = new Date(dateString);
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const diffMs = now.getTime() - date.getTime();
|
|
51
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
52
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
53
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
54
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
55
|
+
|
|
56
|
+
if (diffSecs < 60) return 'Just now';
|
|
57
|
+
if (diffMins < 60) return `${diffMins} min ago`;
|
|
58
|
+
if (diffHours < 24) return `${diffHours} hours ago`;
|
|
59
|
+
if (diffDays < 7) return `${diffDays} days ago`;
|
|
60
|
+
return formatDate(dateString);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Truncate text to a specified length.
|
|
65
|
+
*/
|
|
66
|
+
export function truncate(text: string, length: number): string {
|
|
67
|
+
if (text.length <= length) return text;
|
|
68
|
+
return text.slice(0, length) + '...';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate initials from a name.
|
|
73
|
+
*/
|
|
74
|
+
export function getInitials(name: string): string {
|
|
75
|
+
return name
|
|
76
|
+
.split(' ')
|
|
77
|
+
.map((n) => n[0])
|
|
78
|
+
.join('')
|
|
79
|
+
.toUpperCase()
|
|
80
|
+
.slice(0, 2);
|
|
81
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common TypeScript type definitions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// User types
|
|
6
|
+
export interface User {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
full_name: string;
|
|
10
|
+
role: 'admin' | 'manager' | 'operator' | 'viewer';
|
|
11
|
+
is_active: boolean;
|
|
12
|
+
is_verified: boolean;
|
|
13
|
+
created_at: string;
|
|
14
|
+
updated_at: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Order types
|
|
18
|
+
export type OrderStatus =
|
|
19
|
+
| 'draft'
|
|
20
|
+
| 'pending'
|
|
21
|
+
| 'confirmed'
|
|
22
|
+
| 'processing'
|
|
23
|
+
| 'shipped'
|
|
24
|
+
| 'delivered'
|
|
25
|
+
| 'cancelled'
|
|
26
|
+
| 'refunded';
|
|
27
|
+
|
|
28
|
+
export interface OrderItem {
|
|
29
|
+
id: string;
|
|
30
|
+
product_id: string;
|
|
31
|
+
product_name: string;
|
|
32
|
+
sku: string;
|
|
33
|
+
quantity: number;
|
|
34
|
+
unit_price: number;
|
|
35
|
+
discount_percent: number;
|
|
36
|
+
tax_percent: number;
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Order {
|
|
41
|
+
id: string;
|
|
42
|
+
order_number: string;
|
|
43
|
+
customer_name: string;
|
|
44
|
+
customer_email: string | null;
|
|
45
|
+
status: OrderStatus;
|
|
46
|
+
items: OrderItem[];
|
|
47
|
+
subtotal: number;
|
|
48
|
+
total_discount: number;
|
|
49
|
+
total_tax: number;
|
|
50
|
+
shipping_cost: number;
|
|
51
|
+
grand_total: number;
|
|
52
|
+
currency: string;
|
|
53
|
+
created_at: string;
|
|
54
|
+
updated_at: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Inventory types
|
|
58
|
+
export interface InventoryItem {
|
|
59
|
+
id: string;
|
|
60
|
+
sku: string;
|
|
61
|
+
name: string;
|
|
62
|
+
description: string | null;
|
|
63
|
+
barcode: string | null;
|
|
64
|
+
category: string | null;
|
|
65
|
+
brand: string | null;
|
|
66
|
+
cost_price: number;
|
|
67
|
+
selling_price: number;
|
|
68
|
+
currency: string;
|
|
69
|
+
quantity_on_hand: number;
|
|
70
|
+
quantity_reserved: number;
|
|
71
|
+
quantity_available: number;
|
|
72
|
+
reorder_point: number;
|
|
73
|
+
needs_reorder: boolean;
|
|
74
|
+
is_active: boolean;
|
|
75
|
+
created_at: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Pagination
|
|
79
|
+
export interface PaginatedResponse<T> {
|
|
80
|
+
items: T[];
|
|
81
|
+
total: number;
|
|
82
|
+
skip: number;
|
|
83
|
+
limit: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// API Response types
|
|
87
|
+
export interface ApiError {
|
|
88
|
+
detail: string;
|
|
89
|
+
type?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Auth types
|
|
93
|
+
export interface AuthTokens {
|
|
94
|
+
access_token: string;
|
|
95
|
+
refresh_token: string;
|
|
96
|
+
token_type: string;
|
|
97
|
+
expires_in: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface AuthState {
|
|
101
|
+
user: User | null;
|
|
102
|
+
tokens: AuthTokens | null;
|
|
103
|
+
isAuthenticated: boolean;
|
|
104
|
+
isLoading: boolean;
|
|
105
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Config } from 'tailwindcss';
|
|
2
|
+
|
|
3
|
+
const config: Config = {
|
|
4
|
+
content: [
|
|
5
|
+
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
|
6
|
+
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
|
7
|
+
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
|
8
|
+
],
|
|
9
|
+
darkMode: 'class',
|
|
10
|
+
theme: {
|
|
11
|
+
extend: {
|
|
12
|
+
colors: {
|
|
13
|
+
// Brand colors
|
|
14
|
+
primary: {
|
|
15
|
+
50: '#f0f9ff',
|
|
16
|
+
100: '#e0f2fe',
|
|
17
|
+
200: '#bae6fd',
|
|
18
|
+
300: '#7dd3fc',
|
|
19
|
+
400: '#38bdf8',
|
|
20
|
+
500: '#0ea5e9',
|
|
21
|
+
600: '#0284c7',
|
|
22
|
+
700: '#0369a1',
|
|
23
|
+
800: '#075985',
|
|
24
|
+
900: '#0c4a6e',
|
|
25
|
+
950: '#082f49',
|
|
26
|
+
},
|
|
27
|
+
// Dark theme background
|
|
28
|
+
dark: {
|
|
29
|
+
50: '#f9fafb',
|
|
30
|
+
100: '#f3f4f6',
|
|
31
|
+
200: '#e5e7eb',
|
|
32
|
+
300: '#d1d5db',
|
|
33
|
+
400: '#9ca3af',
|
|
34
|
+
500: '#6b7280',
|
|
35
|
+
600: '#4b5563',
|
|
36
|
+
700: '#374151',
|
|
37
|
+
800: '#1f2937',
|
|
38
|
+
900: '#111827',
|
|
39
|
+
950: '#030712',
|
|
40
|
+
},
|
|
41
|
+
// Success, warning, error
|
|
42
|
+
success: {
|
|
43
|
+
50: '#f0fdf4',
|
|
44
|
+
500: '#22c55e',
|
|
45
|
+
600: '#16a34a',
|
|
46
|
+
},
|
|
47
|
+
warning: {
|
|
48
|
+
50: '#fffbeb',
|
|
49
|
+
500: '#f59e0b',
|
|
50
|
+
600: '#d97706',
|
|
51
|
+
},
|
|
52
|
+
error: {
|
|
53
|
+
50: '#fef2f2',
|
|
54
|
+
500: '#ef4444',
|
|
55
|
+
600: '#dc2626',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
fontFamily: {
|
|
59
|
+
sans: ['Inter', 'system-ui', 'sans-serif'],
|
|
60
|
+
mono: ['JetBrains Mono', 'monospace'],
|
|
61
|
+
},
|
|
62
|
+
spacing: {
|
|
63
|
+
'18': '4.5rem',
|
|
64
|
+
'88': '22rem',
|
|
65
|
+
},
|
|
66
|
+
animation: {
|
|
67
|
+
'fade-in': 'fadeIn 0.3s ease-in-out',
|
|
68
|
+
'slide-up': 'slideUp 0.3s ease-out',
|
|
69
|
+
'slide-down': 'slideDown 0.3s ease-out',
|
|
70
|
+
},
|
|
71
|
+
keyframes: {
|
|
72
|
+
fadeIn: {
|
|
73
|
+
'0%': { opacity: '0' },
|
|
74
|
+
'100%': { opacity: '1' },
|
|
75
|
+
},
|
|
76
|
+
slideUp: {
|
|
77
|
+
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
|
78
|
+
'100%': { transform: 'translateY(0)', opacity: '1' },
|
|
79
|
+
},
|
|
80
|
+
slideDown: {
|
|
81
|
+
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
|
82
|
+
'100%': { transform: 'translateY(0)', opacity: '1' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
plugins: [],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default config;
|