frontend screens implementataions

This commit is contained in:
liyaqath
2026-06-23 11:04:34 +05:30
parent dde3413bfc
commit b01f0debc2
78 changed files with 7748 additions and 2718 deletions
+130 -1
View File
@@ -16,6 +16,7 @@
"@tailwindcss/vite": "^4.3.1",
"axios": "^1.18.0",
"clsx": "^2.1.1",
"formik": "^2.4.9",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
@@ -26,7 +27,8 @@
"react-toastify": "^11.1.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.1",
"yup": "^1.7.1"
},
"devDependencies": {
"@babel/core": "^7.29.0",
@@ -1714,6 +1716,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2446,6 +2460,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2899,6 +2922,31 @@
"node": ">= 6"
}
},
"node_modules/formik": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz",
"integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==",
"funding": [
{
"type": "individual",
"url": "https://opencollective.com/formik"
}
],
"license": "Apache-2.0",
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.1",
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3079,6 +3127,21 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -3513,6 +3576,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3758,6 +3833,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -3824,6 +3905,12 @@
"react": "^19.2.7"
}
},
"node_modules/react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
@@ -4123,12 +4210,24 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -4146,6 +4245,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -4178,6 +4283,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -4797,6 +4914,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+3 -1
View File
@@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.3.1",
"axios": "^1.18.0",
"clsx": "^2.1.1",
"formik": "^2.4.9",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
@@ -28,7 +29,8 @@
"react-toastify": "^11.1.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.1",
"yup": "^1.7.1"
},
"devDependencies": {
"@babel/core": "^7.29.0",
+4 -4
View File
@@ -1,9 +1,9 @@
// src/App.tsx
import { useEffect, useRef, useState } from 'react';
import AppRoutes from './routes/AppRoutes';
import { ToastContainer } from 'react-toastify';
import { LanguageProvider } from './contexts/LanguageContext';
import { useAppDispatch } from './store';
import { setPermissions } from './store/slices/authSlice';
function App() {
const dispatch = useAppDispatch();
@@ -35,10 +35,10 @@ function App() {
}
return (
<>
<LanguageProvider>
<AppRoutes />
<ToastContainer position="top-right" autoClose={3000} />
</>
</LanguageProvider>
);
}
@@ -9,9 +9,9 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children })
// ProtectedRoute for permissions (still useful)
export const ProtectedRoute: React.FC<{
node: string;
node?: string;
children: React.ReactNode;
}> = ({ node, children }) => {
}> = ({ children }) => {
// For now, allow everything in development
// You can later connect real permission logic
return <>{children}</>;
+7 -33
View File
@@ -1,8 +1,7 @@
import { useState } from "react";
import { Navigate, useLocation, Link } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../../store";
import { setCredentials, setLoading } from "../../store/auth.store";
import { authService } from "../services/authService";
import { setAuthLoading } from "../../store/slices/authSlice";
import { Package, Eye, EyeOff, Check } from "lucide-react";
import "./Login.css";
@@ -68,42 +67,17 @@ export const Login = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
dispatch(setLoading(true));
dispatch(setAuthLoading(true));
try {
console.log("Attempting login...");
const response = await authService.login({ email, password });
console.log("Login response:", response);
if (response.success) {
dispatch(
setCredentials({
user: response.data.user,
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
permissions: response.data.permissions,
accessibleStores: response.data.accessibleStores,
isGlobalAccess: response.data.isGlobalAccess,
}),
);
} else {
setError(response.message || "Login failed");
dispatch(setLoading(false));
}
// MOCK LOGIN
setTimeout(() => {
dispatch(setAuthLoading(false));
}, 1000);
} catch (err: unknown) {
console.error("Login error:", err);
// Handle axios errors
if (err && typeof err === "object" && "response" in err) {
const axiosError = err as {
response?: { data?: { message?: string } };
};
setError(axiosError.response?.data?.message || "Login failed");
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("Login failed");
}
dispatch(setLoading(false));
dispatch(setAuthLoading(false));
}
};
+7 -28
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAppDispatch } from '../../store';
import { setCredentials, setLoading } from '../../store/auth.store';
import { authService } from '../services/authService';
import { setAuthLoading } from '../../store/slices/authSlice';
import { Package } from 'lucide-react';
import './Login.css';
@@ -34,34 +33,14 @@ export const SSOCallback = () => {
return;
}
// Exchange code for token
dispatch(setLoading(true));
try {
setStatus('Exchanging authorization code...');
const response = await authService.exchangeSaaSToken(code);
if (response.success && response.data) {
setStatus('Login successful! Redirecting...');
dispatch(
setCredentials({
user: response.data.user,
accessToken: response.data.accessToken || (response.data as any).token,
refreshToken: response.data.refreshToken || (response.data as any).token,
permissions: response.data.permissions || [],
accessibleStores: response.data.accessibleStores,
isGlobalAccess: response.data.isGlobalAccess,
})
);
// Redirect to dashboard after successful login
setTimeout(() => navigate('/dashboard'), 500);
} else {
throw new Error(response.message || 'SSO exchange failed');
}
dispatch(setAuthLoading(true));
// Mock exchange
setStatus('Login successful! Redirecting...');
setTimeout(() => navigate('/dashboard'), 1500);
} catch (err: unknown) {
console.error('SSO Exchange error:', err);
if (err && typeof err === 'object' && 'response' in err) {
const axiosError = err as {
response?: { data?: { message?: string } };
@@ -74,7 +53,7 @@ export const SSOCallback = () => {
}
setStatus('SSO login failed');
dispatch(setLoading(false));
dispatch(setAuthLoading(false));
setTimeout(() => navigate('/login'), 3000);
}
};
+15 -152
View File
@@ -1,152 +1,15 @@
// import { axiosInstance, tokenService } from '../../services';
// import { API_ENDPOINTS } from '../../utils/constants';
// import type { LoginRequest, LoginResponse } from '../types';
// /**
// * Auth service with real API calls
// * Uses centralized TokenService for token management
// */
// export const authService = {
// // Login - calls backend /auth/login endpoint and stores tokens
// login: async (credentials: LoginRequest): Promise<LoginResponse> => {
// const response = await axiosInstance.post(API_ENDPOINTS.AUTH.LOGIN, credentials);
// const data = response.data as LoginResponse;
// // Store tokens using TokenService
// if (data.data?.accessToken) {
// tokenService.setTokens(data.data.accessToken, data.data.refreshToken);
// }
// // Store user data in tokenService for backup
// if (data.data?.user) {
// tokenService.setUserData({
// id: data.data.user.id,
// email: data.data.user.email,
// name: data.data.user.name ?? undefined,
// company_id: data.data.user.company?.id,
// });
// }
// return data;
// },
// // Refresh access token using refresh token
// refreshToken: async (): Promise<{ accessToken: string } | null> => {
// const refreshToken = tokenService.getRefreshToken();
// if (!refreshToken) {
// return null;
// }
// try {
// const response = await axiosInstance.post('/api/v1/auth/refresh-token', {
// refresh_token: refreshToken,
// });
// const { accessToken } = response.data.data;
// // Update stored access token
// tokenService.setTokens(accessToken, refreshToken);
// return { accessToken };
// } catch (error) {
// // Refresh failed - clear all tokens
// tokenService.clearAll();
// return null;
// }
// },
// // Logout - calls backend /auth/logout endpoint and clears tokens
// logout: async (): Promise<void> => {
// try {
// await axiosInstance.post(API_ENDPOINTS.AUTH.LOGOUT);
// } finally {
// // Always clear tokens on logout, even if API call fails
// tokenService.clearAll();
// }
// },
// // Forgot password - sends OTP to email
// forgotPassword: async (email: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/forgot-password', { email });
// return response.data;
// },
// // Verify OTP
// verifyOTP: async (email: string, otp: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/verify-otp', { email, otp });
// return response.data;
// },
// // Reset password with OTP
// resetPassword: async (email: string, otp: string, newPassword: string, confirmPassword: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/reset-password', {
// email,
// otp,
// new_password: newPassword,
// confirm_password: confirmPassword,
// });
// return response.data;
// },
// // Change password (authenticated)
// changePassword: async (oldPassword: string, newPassword: string, confirmPassword: string): Promise<{ message: string }> => {
// const response = await axiosInstance.post('/api/v1/auth/change-password', {
// old_password: oldPassword,
// new_password: newPassword,
// confirm_password: confirmPassword,
// });
// return response.data;
// },
// // Check if user is authenticated
// isAuthenticated: (): boolean => {
// return tokenService.isAuthenticated();
// },
// // Get current user data
// getCurrentUser: () => {
// return tokenService.getUserData();
// },
// // Check if token is expired
// isTokenExpired: (): boolean => {
// return tokenService.isTokenExpired();
// },
// // Check if we have a refresh token
// hasRefreshToken: (): boolean => {
// return !!tokenService.getRefreshToken();
// },
// // Exchange SaaS authorization code for token (SSO)
// exchangeSaaSToken: async (code: string): Promise<LoginResponse> => {
// // Corrected path to match app.js mounting: /api/v1/sso/exchange
// const response = await axiosInstance.post('/api/v1/sso/exchange', { code });
// const data = response.data as LoginResponse;
// // Store tokens using TokenService
// if (data.data?.accessToken || (data.data as any)?.token) {
// const token = data.data?.accessToken || (data.data as any)?.token;
// tokenService.setTokens(token, token);
// }
// // Store user data
// if (data.data?.user) {
// tokenService.setUserData({
// id: data.data.user.id,
// email: data.data.user.email,
// name: data.data.user.name ?? undefined,
// company_id: data.data.user.company?.id,
// });
// }
// return data;
// },
// // Get current user profile from backend
// getMe: async (): Promise<LoginResponse> => {
// const response = await axiosInstance.get('/api/v1/auth/me');
// return response.data;
// },
// };
export const authService = {
login: async (_credentials: any) => ({ data: {} }),
refreshToken: async () => null,
logout: async () => {},
forgotPassword: async (_email: string) => ({ message: "OTP sent" }),
verifyOTP: async (_email: string, _otp: string) => ({ message: "OTP verified" }),
resetPassword: async (_email: string, _otp: string, _newP: string, _confP: string) => ({ message: "Password reset" }),
changePassword: async (_oldP: string, _newP: string, _confP: string) => ({ message: "Password changed" }),
isAuthenticated: () => false,
getCurrentUser: () => null,
isTokenExpired: () => false,
hasRefreshToken: () => false,
exchangeSaaSToken: async (_code: string) => ({ data: {} }),
getMe: async () => ({})
};
+68 -68
View File
@@ -1,74 +1,74 @@
// export interface Store {
// id: number | string;
// store_code: string;
// store_name: string;
// store_type: string;
// }
export interface Store {
id: number | string;
store_code: string;
store_name: string;
store_type: string;
}
// // User information from backend
// export interface User {
// id: number;
// name: string | null;
// email: string;
// type: string;
// company_id: number | null;
// company: {
// id: number;
// company_name: string;
// } | null;
// roles?: {
// id: number;
// name: string;
// code: string;
// }[];
// stores: Store[];
// store_id?: number | string;
// }
// User information from backend
export interface User {
id: number;
name: string | null;
email: string;
type: string;
company_id: number | null;
company: {
id: number;
company_name: string;
} | null;
roles?: {
id: number;
name: string;
code: string;
}[];
stores: Store[];
store_id?: number | string;
}
// // Permission actions available for each module
// export interface PermissionActions {
// view?: boolean;
// create?: boolean;
// edit?: boolean;
// delete?: boolean;
// alter?: boolean;
// import?: boolean;
// export?: boolean;
// }
// Permission actions available for each module
export interface PermissionActions {
view?: boolean;
create?: boolean;
edit?: boolean;
delete?: boolean;
alter?: boolean;
import?: boolean;
export?: boolean;
}
// // Permissions structure: nested object from backend
// export type Permissions = Record<string, PermissionActions | boolean>;
// Permissions structure: nested object from backend
export type Permissions = Record<string, PermissionActions | boolean>;
// // Authentication state in Redux store
// export interface AuthState {
// user: User | null;
// accessToken: string | null;
// permissions: Permissions;
// isAuthenticated: boolean;
// isLoading: boolean;
// currentStore: Store | null;
// accessibleStores: Store[];
// isGlobalAccess: boolean;
// }
// Authentication state in Redux store
export interface AuthState {
user: User | null;
accessToken: string | null;
permissions: Permissions;
isAuthenticated: boolean;
isLoading: boolean;
currentStore: Store | null;
accessibleStores: Store[];
isGlobalAccess: boolean;
}
// // API response from login endpoint
// export interface LoginResponse {
// StatusCode: number;
// data: {
// token: any;
// user: User;
// accessToken: string;
// refreshToken?: string;
// permissions: Permissions;
// accessibleStores?: Store[];
// isGlobalAccess?: boolean;
// };
// message: string;
// success: boolean;
// }
// API response from login endpoint
export interface LoginResponse {
StatusCode: number;
data: {
token: any;
user: User;
accessToken: string;
refreshToken?: string;
permissions: Permissions;
accessibleStores?: Store[];
isGlobalAccess?: boolean;
};
message: string;
success: boolean;
}
// // Login request payload
// export interface LoginRequest {
// email: string;
// password: string;
// }
// Login request payload
export interface LoginRequest {
email: string;
password: string;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export interface InfoCardProps {
icon: ReactNode;
trend?: string;
trendDirection?: "up" | "down" | "neutral";
subtitle?: string;
subtitle?: ReactNode;
variant?: InfoCardVariant;
className?: string;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { type ReactNode, useState, useMemo } from "react";
import { ChevronUp, ChevronDown, MoreVertical } from "lucide-react";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Button } from "./Button";
export interface TableColumn<T = any> {
+23 -2
View File
@@ -1,16 +1,20 @@
// src/components/layouts/Headers.tsx
import React, { useState } from "react";
import { useLocation } from "react-router-dom";
import { Bell, ChevronDown, Building2 } from "lucide-react";
import { Bell, ChevronDown, Building2, Globe } from "lucide-react";
import { useLanguage, type Language } from "../../contexts/LanguageContext";
const PAGE_TITLES: Record<string, { title: string; subtitle?: string }> = {
"/dashboard": { title: "Dashboard", subtitle: "Overview of your PIM platform" },
"/products": { title: "Products", subtitle: "Manage your product catalog" },
"/families": { title: "Product Families", subtitle: "Manage product families" },
"/reports": { title: "Reports & Analytics", subtitle: "Comprehensive insights into your product catalog performance" },
"/users": { title: "Users & Roles", subtitle: "Manage team members, permissions, and access controls" },
"/settings": { title: "Settings", subtitle: "System configurations and preferences" },
};
export function Header() {
const location = useLocation();
const { language, setLanguage } = useLanguage();
const page = PAGE_TITLES[location.pathname] || { title: "PIM Platform" };
@@ -29,6 +33,23 @@ export function Header() {
{/* Right Side Controls */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 hover:bg-surface-muted transition-colors">
<Globe className="w-4 h-4 text-muted-foreground" />
<select
value={language}
onChange={(e) => setLanguage(e.target.value as Language)}
className="text-sm font-medium bg-transparent outline-none cursor-pointer"
aria-label="Select language"
>
<option value="en">English</option>
<option value="hi">Hindi</option>
<option value="ar">Arabic</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="de">German</option>
</select>
</div>
<button className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border hover:bg-surface-muted">
<Building2 className="w-4 h-4" />
<span>Acme Corp</span>
+1 -3
View File
@@ -1,11 +1,9 @@
// src/components/layouts/AppLayout.tsx
import { Outlet, useLocation } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { Header } from './Headers';
import { Sidebar } from './Sidebar';
const MainLayout = () => {
const location = useLocation();
const isSpecialPage = location.pathname.includes('/pos') || false; // Add special cases if needed
return (
<div className="flex h-screen overflow-hidden bg-background">
-11
View File
@@ -5,18 +5,7 @@ import type { RootState } from "../../store";
import { hasPermission } from "../../utils/permissionUtils";
import { useLocation, useNavigate } from "react-router-dom";
import {
LayoutDashboard,
Package,
FolderTree,
Grid3x3,
Tag,
Layers,
Upload,
Workflow,
Settings,
Database,
Ruler,
Award,
ChevronDown,
ChevronRight,
} from "lucide-react";
+32
View File
@@ -0,0 +1,32 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
export type Language = 'en' | 'es' | 'fr' | 'hi' | 'ar' | 'de';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [language, setLanguage] = useState<Language>('en');
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
return (
<LanguageContext.Provider value={{ language, setLanguage }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
@@ -1,55 +1,268 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useState } from "react";
import { Plus, Edit2, Image as ImageIcon, Video, FileText, Tag, Search, ChevronUp, ChevronDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useAssetFamily } from "../hook/useAssetFamily";
import type { AssetFamily } from "../types/asset-families.types";
// Mock Data
const MOCK_FAMILIES = [
{
id: "f1",
name: "Product Photos",
description: "Main product imagery for ecommerce and marketing",
code: "product_photos",
type: "IMAGE",
assets: "8,942",
icon: ImageIcon,
iconBg: "bg-blue-50",
iconColor: "text-blue-600",
attributes: [
{ code: "alt_text", label: "Alt Text", type: "text", required: true, options: null },
{ code: "copyright", label: "Copyright", type: "text", required: true, options: null },
{ code: "photographer", label: "Photographer", type: "text", required: false, options: null },
{ code: "shoot_date", label: "Shoot Date", type: "date", required: false, options: null },
{ code: "usage_rights", label: "Usage Rights", type: "select", required: true, options: "3 options" },
{ code: "locale", label: "Locale", type: "select", required: false, options: "45 options" },
{ code: "background_color", label: "Background Color", type: "text", required: false, options: null },
{ code: "is_main", label: "Is Main Image", type: "boolean", required: false, options: null },
]
},
{
id: "f2",
name: "Marketing Images",
description: "Banners, campaigns, and promotional imagery",
code: "marketing_images",
type: "IMAGE",
assets: "1,247",
icon: ImageIcon,
iconBg: "bg-purple-50",
iconColor: "text-purple-600",
attributes: [
{ code: "campaign_name", label: "Campaign Name", type: "text", required: true, options: null },
{ code: "channel", label: "Channel", type: "select", required: true, options: "12 options" },
{ code: "valid_from", label: "Valid From", type: "date", required: false, options: null },
{ code: "valid_to", label: "Valid To", type: "date", required: false, options: null },
]
},
{
id: "f3",
name: "Technical Documentation",
description: "User manuals, spec sheets, and technical diagrams",
code: "technical_docs",
type: "DOCUMENT",
assets: "543",
icon: FileText,
iconBg: "bg-amber-50",
iconColor: "text-amber-600",
attributes: [
{ code: "doc_version", label: "Document Version", type: "text", required: true, options: null },
{ code: "language", label: "Language", type: "select", required: true, options: "30 options" },
{ code: "revision_date", label: "Revision Date", type: "date", required: false, options: null },
]
},
{
id: "f4",
name: "Product Videos",
description: "Product demonstrations and promotional videos",
code: "product_videos",
type: "VIDEO",
assets: "287",
icon: Video,
iconBg: "bg-red-50",
iconColor: "text-red-600",
attributes: [
{ code: "duration", label: "Duration (secs)", type: "text", required: true, options: null },
{ code: "resolution", label: "Resolution", type: "select", required: true, options: "5 options" },
{ code: "captions", label: "Has Captions", type: "boolean", required: false, options: null },
]
}
];
const TYPE_COLORS: Record<string, string> = {
IMAGE: "text-blue-600 bg-blue-50 border-blue-200",
DOCUMENT: "text-amber-600 bg-amber-50 border-amber-200",
VIDEO: "text-red-600 bg-red-50 border-red-200",
};
export default function AssetFamilyList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useAssetFamily();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedFamily, setSelectedFamily] = useState(MOCK_FAMILIES[0]);
const [attributesOpen, setAttributesOpen] = useState(true);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: AssetFamily) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
];
const actions = (item: AssetFamily) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
</div>
const filtered = MOCK_FAMILIES.filter(f =>
f.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">AssetFamilys</h1>
<p className="text-sm text-gray-500">Manage your asset families</p>
<nav className="text-xs text-gray-500 mb-2 flex items-center gap-1.5">
<span>Asset Management</span>
<span></span>
<span className="text-gray-900 font-medium">Asset Families</span>
</nav>
<h1 className="text-2xl font-bold text-gray-900">Asset Families</h1>
<p className="text-sm text-gray-500 mt-1">Configure asset types, attributes, and transformations for your digital asset library</p>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create AssetFamily
<Button className="bg-purple-600 hover:bg-purple-700 text-white" onClick={() => navigate("new")}>
<Plus className="w-4 h-4 mr-2" />
Create Asset Family
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
<div className="flex gap-6 h-[calc(100vh-230px)]">
{/* Left Sidebar */}
<div className="w-72 shrink-0 flex flex-col bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search asset families..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtered.map((family) => {
const Icon = family.icon;
const isSelected = selectedFamily.id === family.id;
return (
<div
key={family.id}
onClick={() => setSelectedFamily(family)}
className={`p-3 rounded-xl mb-1.5 cursor-pointer transition-all ${
isSelected ? "bg-purple-50 border border-purple-200 ring-1 ring-purple-100" : "border border-transparent hover:bg-gray-50 hover:border-gray-200"
}`}
>
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${family.iconBg}`}>
<Icon className={`w-4.5 h-4.5 ${family.iconColor}`} />
</div>
<div className="min-w-0">
<div className={`font-semibold text-sm truncate ${isSelected ? "text-purple-900" : "text-gray-900"}`}>
{family.name}
</div>
<p className="text-xs text-gray-500 truncate mt-0.5">{family.description}</p>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
{family.code}
</span>
<span className="text-[10px] text-gray-400">{family.assets} assets</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Right Detail Panel */}
<div className="flex-1 flex flex-col gap-4 overflow-y-auto">
{/* Family Header Card */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex justify-between items-start">
<div className="flex items-start gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${selectedFamily.iconBg}`}>
<selectedFamily.icon className={`w-6 h-6 ${selectedFamily.iconColor}`} />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">{selectedFamily.name}</h2>
<p className="text-sm text-gray-500 mt-1">{selectedFamily.description}</p>
<div className="flex items-center gap-3 mt-3">
<div className="flex items-center gap-1.5 text-sm text-gray-600">
<span className="text-gray-400">Code:</span>
<code className="font-mono text-xs bg-gray-100 border border-gray-200 px-2 py-0.5 rounded text-gray-700">
{selectedFamily.code}
</code>
</div>
<div className="flex items-center gap-1.5 text-sm text-gray-600">
<span className="text-gray-400">Type:</span>
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${TYPE_COLORS[selectedFamily.type]}`}>
{selectedFamily.type}
</span>
</div>
<div className="flex items-center gap-1.5 text-sm text-gray-600">
<span className="text-gray-400">Assets:</span>
<span className="font-semibold text-gray-900">{selectedFamily.assets}</span>
</div>
</div>
</div>
</div>
<Button variant="outline" className="text-gray-700 shrink-0" onClick={() => navigate(selectedFamily.id + "/edit")}>
<Edit2 className="w-4 h-4 mr-2" />
Edit Family
</Button>
</div>
</div>
{/* Asset Attributes Card */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-purple-500" />
<h3 className="font-semibold text-gray-900">Asset Attributes</h3>
<span className="text-sm text-gray-500">Metadata fields required for assets in this family</span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{selectedFamily.attributes.length} attributes</span>
<button
onClick={() => setAttributesOpen(!attributesOpen)}
className="p-1 text-gray-400 hover:text-gray-700 rounded transition-colors"
>
{attributesOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{attributesOpen && (
<div>
<div className="grid grid-cols-5 gap-4 px-6 py-3 border-b border-gray-100 bg-gray-50/50">
<div className="text-xs font-bold text-gray-500 tracking-wider uppercase col-span-1">Attribute Code</div>
<div className="text-xs font-bold text-gray-500 tracking-wider uppercase">Label</div>
<div className="text-xs font-bold text-gray-500 tracking-wider uppercase">Type</div>
<div className="text-xs font-bold text-gray-500 tracking-wider uppercase">Required</div>
<div className="text-xs font-bold text-gray-500 tracking-wider uppercase">Options</div>
</div>
<div className="divide-y divide-gray-100">
{selectedFamily.attributes.map((attr, i) => (
<div key={i} className="grid grid-cols-5 gap-4 px-6 py-3.5 hover:bg-gray-50/70 transition-colors">
<div className="col-span-1">
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200">
{attr.code}
</code>
</div>
<div className="text-sm text-gray-900 font-medium">{attr.label}</div>
<div className="text-sm text-gray-600">{attr.type}</div>
<div>
{attr.required ? (
<span className="px-2 py-0.5 rounded text-xs font-bold bg-red-50 text-red-600 border border-red-200">
Required
</span>
) : (
<span className="text-xs text-gray-400">Optional</span>
)}
</div>
<div className="text-sm text-gray-500">{attr.options || "—"}</div>
</div>
))}
</div>
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/30">
<button className="flex items-center gap-2 text-sm font-medium text-purple-600 hover:text-purple-800 transition-colors">
<Plus className="w-4 h-4" />
Add Attribute
</button>
</div>
</div>
)}
</div>
</div>
</div>
</PageWrapper>
);
@@ -1,69 +1,131 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { ArrowLeft, Save } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useAssetFamily } from "../hook/useAssetFamily";
import { validateAssetFamily } from "../validation/asset-families.schema";
import { assetFamiliesService } from "../services/asset-families.service";
import { assetFamilySchema } from "../validation/asset-families.schema";
import type { AssetFamilyCreateRequest } from "../types/asset-families.types";
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
];
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewAssetFamily() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useAssetFamily();
const [form, setForm] = useState<Partial<AssetFamilyCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const { createItem, updateItem, items, fetchItems } = useAssetFamily();
useEffect(() => {
if (isEdit && id) {
assetFamiliesService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
});
}
}, [isEdit, id]);
fetchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateAssetFamily(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as AssetFamilyCreateRequest);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: assetFamilySchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as AssetFamilyCreateRequest);
}
navigate("..");
} catch {
// Handled by service/hook
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id && items.length > 0) {
const match = items.find((item) => item.id === id);
if (match) {
formik.setValues({
name: match.name,
status: match.status as "active" | "inactive",
});
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} AssetFamily</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
type="button"
onClick={() => navigate("..")}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Asset Family' : 'Create Asset Family'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update asset family details' : 'Add a new asset family'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Documents"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<select
name="status"
className={inputClass()}
value={formik.values.status}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("..")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{formik.isSubmitting ? 'Saving…' : isEdit ? 'Update Asset Family' : 'Create Asset Family'}
</Button>
</div>
</form>
</PageWrapper>
);
}
@@ -1,6 +1,6 @@
import type { AssetFamilyCreateRequest } from '../types/asset-families.types';
export const validateAssetFamily = (data: Partial<AssetFamilyCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'AssetFamily name is required';
return errors;
};
import * as Yup from 'yup';
export const assetFamilySchema = Yup.object().shape({
name: Yup.string().required('Asset Family name is required'),
status: Yup.string().oneOf(['active', 'inactive']),
});
+271 -41
View File
@@ -1,55 +1,285 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useState } from "react";
import { Plus, Edit2, Trash2, Eye, Search, Filter, Image as ImageIcon, Video, FileText, CheckCircle2, Layers, RefreshCw, LayoutGrid, BarChart2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useAssetType } from "../hook/useAssetType";
import type { AssetType } from "../types/asset-types.types";
// Mock Data
const MOCK_ASSET_TYPES = [
{
id: "1",
name: "Primary Image",
description: "Main hero image displayed as the primary product photo on all...",
required: true,
code: "primary_image",
category: "Image",
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
icon: ImageIcon,
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
status: "Active",
families: 48,
products: "12,840",
updatedDate: "2025-03-15",
updatedBy: "Admin User"
},
{
id: "2",
name: "Gallery Image",
description: "Additional product images for gallery carousel and zoom...",
required: false,
code: "gallery_image",
category: "Image",
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
icon: ImageIcon,
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
status: "Active",
families: 42,
products: "10,240",
updatedDate: "2025-03-12",
updatedBy: "Sarah Chen"
},
{
id: "3",
name: "Thumbnail",
description: "Small optimized image used in product listings, search results and...",
required: false,
code: "thumbnail",
category: "Image",
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
icon: ImageIcon,
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
status: "Active",
families: 38,
products: "9,820",
updatedDate: "2025-02-28",
updatedBy: "Michael Torres"
},
{
id: "4",
name: "360° Image",
description: "Sequence of images enabling interactive 360-degree product...",
required: false,
code: "image_360",
category: "Image",
categoryColor: "text-blue-600 bg-blue-50 border-blue-200",
icon: ImageIcon,
fileTypes: ["JPG", "JPEG", "PNG", "+1"],
status: "Active",
families: 14,
products: "2,430",
updatedDate: "2025-03-08",
updatedBy: "Emma Wilson"
},
{
id: "5",
name: "Product Video",
description: "Demonstration or promotional video showcasing product features...",
required: false,
code: "product_video",
category: "Video",
categoryColor: "text-red-600 bg-red-50 border-red-200",
icon: Video,
fileTypes: ["MP4", "MOV", "WEBM"],
status: "Active",
families: 22,
products: "4,310",
updatedDate: "2025-03-10",
updatedBy: "James Park"
},
{
id: "6",
name: "Datasheet PDF",
description: "Detailed technical specifications, safety information, and...",
required: false,
code: "datasheet_pdf",
category: "Document",
categoryColor: "text-amber-600 bg-amber-50 border-amber-200",
icon: FileText,
fileTypes: ["PDF"],
status: "Active",
families: 30,
products: "7,840",
updatedDate: "2025-03-05",
updatedBy: "Admin User"
}
];
export default function AssetTypeList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useAssetType();
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: AssetType) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
];
const actions = (item: AssetType) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
</div>
);
const [searchQuery, setSearchQuery] = useState("");
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">AssetTypes</h1>
<p className="text-sm text-gray-500">Manage your asset types</p>
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-600 rounded-xl flex items-center justify-center">
<LayoutGrid className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Asset Types</h1>
<p className="text-sm text-gray-500 mt-1">Define reusable asset classifications for product families and products</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" className="text-gray-700 bg-white">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white" onClick={() => navigate("new")}>
<Plus className="w-4 h-4 mr-2" />
Add Asset Type
</Button>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create AssetType
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-2 text-gray-500">
<LayoutGrid className="w-4 h-4 text-purple-500" />
<h3 className="text-xs font-bold tracking-wider uppercase">Total Asset Types</h3>
</div>
<div className="text-3xl font-bold text-gray-900">12</div>
<div className="text-xs text-gray-500 mt-1">across all categories</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-2 text-gray-500">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
<h3 className="text-xs font-bold tracking-wider uppercase">Active Types</h3>
</div>
<div className="text-3xl font-bold text-gray-900">11</div>
<div className="text-xs text-gray-500 mt-1">1 inactive</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-2 text-gray-500">
<Layers className="w-4 h-4 text-blue-500" />
<h3 className="text-xs font-bold tracking-wider uppercase">Families Using</h3>
</div>
<div className="text-3xl font-bold text-gray-900">286</div>
<div className="text-xs text-gray-500 mt-1">family assignments</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="flex items-center gap-2 mb-2 text-gray-500">
<BarChart2 className="w-4 h-4 text-amber-500" />
<h3 className="text-xs font-bold tracking-wider uppercase">Products Using</h3>
</div>
<div className="text-3xl font-bold text-gray-900">68,420</div>
<div className="text-xs text-gray-500 mt-1">product associations</div>
</div>
</div>
{/* Toolbar */}
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search asset types..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="flex items-center border border-gray-200 rounded-lg bg-white px-3 py-2 text-sm text-gray-600 gap-2 cursor-pointer hover:bg-gray-50">
<Filter className="w-4 h-4 text-gray-400" />
<span>All Categories</span>
</div>
<div className="flex items-center border border-gray-200 rounded-lg bg-white px-3 py-2 text-sm text-gray-600 gap-2 cursor-pointer hover:bg-gray-50">
<span>All Statuses</span>
</div>
</div>
<div className="text-sm text-gray-500">
12 of 12 types
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-x-auto min-h-[500px]">
<table className="w-full text-left border-collapse min-w-max">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/50">
<th className="py-4 px-6 text-xs font-bold text-gray-500 tracking-wider uppercase">Asset Type Name</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Code</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Category</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">File Types</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Status</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Families</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Products</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Updated</th>
<th className="py-4 px-6 text-xs font-bold text-gray-500 tracking-wider uppercase text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{MOCK_ASSET_TYPES.map((type) => {
const Icon = type.icon;
return (
<tr key={type.id} className="hover:bg-gray-50/80 transition-colors group">
<td className="py-4 px-6 max-w-sm">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded bg-blue-50 text-blue-600 flex items-center justify-center mt-1 shrink-0">
<Icon className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{type.name}</span>
{type.required && (
<span className="px-1.5 py-0.5 text-[10px] font-bold uppercase rounded bg-red-50 text-red-600">Required</span>
)}
</div>
<p className="text-xs text-gray-500 mt-1 truncate max-w-xs">{type.description}</p>
</div>
</div>
</td>
<td className="py-4 px-4">
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded border border-gray-200">
{type.code}
</code>
</td>
<td className="py-4 px-4">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${type.categoryColor}`}>
<Icon className="w-3.5 h-3.5" />
{type.category}
</span>
</td>
<td className="py-4 px-4">
<div className="flex flex-wrap gap-1 w-32">
{type.fileTypes.map((ft, i) => (
<span key={i} className="text-[10px] font-bold text-gray-600 uppercase">
{ft}{i < type.fileTypes.length - 1 && ' '}
</span>
))}
</div>
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
<span className="text-xs font-medium text-emerald-700">{type.status}</span>
</div>
</td>
<td className="py-4 px-4">
<span className="font-medium text-gray-900">{type.families}</span>
</td>
<td className="py-4 px-4">
<span className="font-medium text-gray-900">{type.products}</span>
</td>
<td className="py-4 px-4">
<div className="text-xs text-gray-900">{type.updatedDate}</div>
<div className="text-[11px] text-gray-500">{type.updatedBy}</div>
</td>
<td className="py-4 px-6 text-right">
<div className="flex items-center justify-end gap-2 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-red-600 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</PageWrapper>
);
+109 -47
View File
@@ -1,69 +1,131 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { ArrowLeft, Save } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useAssetType } from "../hook/useAssetType";
import { validateAssetType } from "../validation/asset-types.schema";
import { assetTypesService } from "../services/asset-types.service";
import { assetTypeSchema } from "../validation/asset-types.schema";
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
];
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewAssetType() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useAssetType();
const [form, setForm] = useState<Partial<AssetTypeCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const { createItem, updateItem, items, fetchItems } = useAssetType();
useEffect(() => {
if (isEdit && id) {
assetTypesService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
});
}
}, [isEdit, id]);
fetchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateAssetType(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as AssetTypeCreateRequest);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: assetTypeSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as AssetTypeCreateRequest);
}
navigate("..");
} catch {
// Handled by service/hook
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id && items.length > 0) {
const match = items.find((item) => item.id === id);
if (match) {
formik.setValues({
name: match.name,
status: match.status as "active" | "inactive",
});
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} AssetType</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
type="button"
onClick={() => navigate("..")}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Asset Type' : 'Create Asset Type'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update asset type details' : 'Add a new asset type'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Image"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<select
name="status"
className={inputClass()}
value={formik.values.status}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("..")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{formik.isSubmitting ? 'Saving…' : isEdit ? 'Update Asset Type' : 'Create Asset Type'}
</Button>
</div>
</form>
</PageWrapper>
);
}
@@ -1,6 +1,6 @@
import type { AssetTypeCreateRequest } from '../types/asset-types.types';
export const validateAssetType = (data: Partial<AssetTypeCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'AssetType name is required';
return errors;
};
import * as Yup from 'yup';
export const assetTypeSchema = Yup.object().shape({
name: Yup.string().required('Asset Type name is required'),
status: Yup.string().oneOf(['active', 'inactive']),
});
+465 -47
View File
@@ -1,56 +1,474 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { useState } from "react";
import {
Search, ChevronDown, Download, Archive, Upload,
Image as ImageIcon, Video, FileText, Eye, Edit2, List, Grid,
FileBadge, BookOpen, Presentation, PenTool, Package, Layers,
ChevronRight
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useAsset } from "../hook/useAsset";
import type { Asset } from "../types/assets.types";
// Mock Data
const ASSET_CATEGORIES = [
{ id: "all", name: "All Assets", count: 125, icon: Layers, active: true },
{ id: "product-images", name: "Product Images", count: 45, icon: ImageIcon },
{ id: "variant-images", name: "Variant Images", count: 32, icon: Package },
{ id: "videos", name: "Videos", count: 8, icon: Video },
{ id: "documents", name: "Documents", count: 15, icon: FileText },
{ id: "certificates", name: "Certificates", count: 12, icon: FileBadge },
{ id: "manuals", name: "User Manuals", count: 6, icon: BookOpen },
{ id: "marketing", name: "Marketing Assets", count: 18, icon: Presentation },
{ id: "drawings", name: "Technical Drawings", count: 4, icon: PenTool },
{ id: "packaging", name: "Packaging Assets", count: 10, icon: Package },
];
const MOCK_ASSETS = [
{
id: "1",
name: "Nike Air Max Front View",
ext: "JPG",
type: "Image",
typeColor: "text-blue-600 bg-blue-50 border-blue-200",
iconColor: "text-blue-500",
product: "Nike Air Max",
productYear: "2024",
size: "2.4 MB",
resolution: "4000x3000",
tags: ["front", "product", "+1"],
status: "Active",
},
{
id: "2",
name: "Nike Air Max Back View",
ext: "JPG",
type: "Image",
typeColor: "text-blue-600 bg-blue-50 border-blue-200",
iconColor: "text-blue-500",
product: "Nike Air Max",
productYear: "2024",
size: "2.1 MB",
resolution: "4000x3000",
tags: ["back", "product", "+1"],
status: "Active",
},
{
id: "3",
name: "Product Demo Video",
ext: "MP4",
type: "Video",
typeColor: "text-purple-600 bg-purple-50 border-purple-200",
iconColor: "text-purple-500",
product: "Nike Air Max",
productYear: "2024",
size: "45.2 MB",
resolution: "1920x1080",
tags: ["demo", "marketing", "+1"],
status: "Active",
},
{
id: "4",
name: "Product Manual",
ext: "PDF",
type: "Manual",
typeColor: "text-blue-600 bg-blue-50 border-blue-200",
iconColor: "text-gray-500",
product: "Nike Air Max",
productYear: "2024",
size: "3.5 MB",
resolution: "-",
tags: ["manual", "documentation"],
status: "Active",
},
];
const MOCK_VARIANT_GROUPS = [
{
id: "v1",
product: "Nike Air Max 2024",
variantsCount: 4,
variants: [
{ name: "Black / 8", assets: 3 },
{ name: "Black / 9", assets: 3 },
{ name: "White / 8", assets: 3 },
{ name: "White / 9", assets: 3 },
]
},
{
id: "v2",
product: "MacBook Pro M4",
variantsCount: 4,
variants: [
{ name: "Black / 8", assets: 3 },
{ name: "Black / 9", assets: 3 },
{ name: "White / 8", assets: 3 },
{ name: "White / 9", assets: 3 },
]
},
{
id: "v3",
product: "Samsung QLED TV",
variantsCount: 4,
variants: [
{ name: "Black / 8", assets: 3 },
{ name: "Black / 9", assets: 3 },
{ name: "White / 8", assets: 3 },
{ name: "White / 9", assets: 3 },
]
}
];
export default function AssetList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useAsset();
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Asset) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
const [activeTab, setActiveTab] = useState("Product Assets");
const getAssetIcon = (type: string, color: string) => {
if (type === "Image") return <ImageIcon className={`w-5 h-5 ${color}`} />;
if (type === "Video") return <Video className={`w-5 h-5 ${color}`} />;
return <FileText className={`w-5 h-5 ${color}`} />;
};
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
];
return (
<div className="flex h-[calc(100vh-64px)] bg-gray-50/50">
{/* Left Sidebar */}
<div className="w-64 bg-white border-r border-gray-200 flex flex-col h-full overflow-y-auto">
<div className="p-4 border-b border-gray-100">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search assets..."
className="w-full pl-9 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
const actions = (item: Asset) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
<div className="p-4 border-b border-gray-100">
<h3 className="text-xs font-bold text-gray-500 tracking-wider mb-3">FILTERS</h3>
<div className="space-y-4">
<div>
<label className="text-xs text-gray-500 mb-1.5 block">Asset Type</label>
<div className="relative">
<select className="w-full appearance-none bg-white border border-gray-200 text-gray-700 py-2 px-3 pr-8 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500">
<option>All Types</option>
</select>
<ChevronDown className="w-4 h-4 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
<div>
<label className="text-xs text-gray-500 mb-1.5 block">Status</label>
<div className="relative">
<select className="w-full appearance-none bg-white border border-gray-200 text-gray-700 py-2 px-3 pr-8 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500">
<option>All Status</option>
</select>
<ChevronDown className="w-4 h-4 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
</div>
</div>
<div className="p-4 flex-1">
<h3 className="text-xs font-bold text-gray-500 tracking-wider mb-2">ASSET CATEGORIES</h3>
<ul className="space-y-1">
{ASSET_CATEGORIES.map((cat) => (
<li key={cat.id}>
<button className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${cat.active ? 'bg-purple-50 text-purple-700 font-medium' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}`}>
<div className="flex items-center gap-2.5">
<cat.icon className="w-4 h-4" />
{cat.name}
</div>
<span className={cat.active ? 'text-purple-600' : 'text-gray-400'}>{cat.count}</span>
</button>
</li>
))}
</ul>
</div>
<div className="p-4">
<div className="bg-purple-50/50 rounded-xl p-4 border border-purple-100/50">
<h3 className="text-xs font-bold text-gray-700 tracking-wider mb-3">STORAGE SUMMARY</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-600">Total Assets</span>
<span className="font-medium text-gray-900">10</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Storage Used</span>
<span className="font-medium text-purple-600">2.8 GB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Active</span>
<span className="font-medium text-emerald-600">8</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Pending Review</span>
<span className="font-medium text-amber-600">1</span>
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-6 py-5 flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900">Product Asset Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage images, videos, documents, certificates, manuals, marketing content and variant-specific assets</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" className="text-gray-700">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
<Button variant="outline" className="text-gray-700">
<Archive className="w-4 h-4 mr-2" />
Archive
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
<Upload className="w-4 h-4 mr-2" />
Upload Asset
</Button>
</div>
</div>
{/* Tabs */}
<div className="bg-white px-6 border-b border-gray-200 flex gap-6">
{["Product Assets", "Variant Assets", "Asset Library", "Asset Relations", "Usage Analytics"].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-3.5 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
activeTab === tab
? 'border-purple-600 text-purple-700'
: 'border-transparent text-gray-500 hover:text-gray-900 hover:border-gray-300'
}`}
>
{tab === "Product Assets" && <Package className="w-4 h-4" />}
{tab === "Variant Assets" && <Grid className="w-4 h-4" />}
{tab === "Asset Library" && <Layers className="w-4 h-4" />}
{tab}
</button>
))}
</div>
{/* Toolbar (Only for Product Assets) */}
{activeTab === "Product Assets" && (
<div className="px-6 py-4 flex justify-between items-center">
<Button variant="outline" className="text-gray-700 bg-white">
<Upload className="w-4 h-4 mr-2" />
Bulk Upload
</Button>
<div className="flex items-center gap-1 bg-white border border-gray-200 rounded-lg p-1">
<button className="p-1.5 bg-purple-50 text-purple-600 rounded shadow-sm">
<List className="w-4 h-4" />
</button>
<button className="p-1.5 text-gray-400 hover:text-gray-600">
<Grid className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Content Area */}
<div className="flex-1 overflow-auto px-6 pb-6 pt-4">
{activeTab === "Product Assets" && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 min-w-max">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-gray-200">
<th className="py-4 px-4 pl-6">
<input type="checkbox" className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Preview</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Asset Name</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Asset Type</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Product</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">File Size</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Resolution</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Tags</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase">Status</th>
<th className="py-4 px-4 text-xs font-bold text-gray-500 tracking-wider uppercase text-right pr-6">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{MOCK_ASSETS.map((asset) => (
<tr key={asset.id} className="hover:bg-gray-50 transition-colors group">
<td className="py-4 px-4 pl-6 w-12">
<input type="checkbox" className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
</td>
<td className="py-4 px-4 w-24">
<div className="w-12 h-12 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center">
{getAssetIcon(asset.type, asset.iconColor)}
</div>
</td>
<td className="py-4 px-4 min-w-[180px]">
<div className="font-medium text-gray-900">{asset.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{asset.ext}</div>
</td>
<td className="py-4 px-4">
<span className={`inline-flex items-center px-2.5 py-1 rounded border text-xs font-medium ${asset.typeColor}`}>
{asset.type}
</span>
</td>
<td className="py-4 px-4">
<div className="text-sm text-gray-900">{asset.product}</div>
<div className="text-xs text-gray-500 mt-0.5">{asset.productYear}</div>
</td>
<td className="py-4 px-4 text-sm text-gray-600 whitespace-nowrap">
{asset.size}
</td>
<td className="py-4 px-4 text-sm text-gray-600 whitespace-nowrap">
{asset.resolution}
</td>
<td className="py-4 px-4 min-w-[150px]">
<div className="flex flex-wrap gap-1.5">
{asset.tags.map((tag, i) => (
<span key={i} className="inline-flex px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200 text-[11px] font-medium">
{tag}
</span>
))}
</div>
</td>
<td className="py-4 px-4">
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
{asset.status}
</span>
</td>
<td className="py-4 px-4 pr-6 text-right">
<div className="flex items-center justify-end gap-2 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded border border-transparent hover:border-gray-200 shadow-sm transition-all"><Edit2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === "Variant Assets" && (
<div>
<div className="mb-4">
<h2 className="text-lg font-semibold text-gray-900">Variant Asset Management</h2>
<p className="text-sm text-gray-500">Manage assets for specific product variants</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{MOCK_VARIANT_GROUPS.map((group) => (
<div key={group.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-purple-50 flex items-center justify-center border border-purple-100">
<Package className="w-5 h-5 text-purple-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">{group.product}</h3>
<p className="text-xs text-gray-500">{group.variantsCount} variants</p>
</div>
</div>
<div className="space-y-2">
{group.variants.map((v, i) => (
<div key={i} className="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/50 transition-colors cursor-pointer group/item">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-gray-100 flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-gray-400" />
</div>
<div>
<div className="text-sm font-medium text-gray-900">{v.name}</div>
<div className="text-xs text-gray-500">{v.assets} assets</div>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-400 group-hover/item:text-purple-600" />
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{activeTab === "Usage Analytics" && (
<div className="space-y-6">
{/* KPIs */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Total Assets</h3>
<div className="text-3xl font-bold text-gray-900">10</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Most Used Asset</h3>
<div className="text-lg font-bold text-gray-900">Nike Front View</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Orphan Assets</h3>
<div className="text-3xl font-bold text-amber-500">3</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<h3 className="text-sm font-medium text-gray-500 mb-2">Missing Alt Text</h3>
<div className="text-3xl font-bold text-red-500">8</div>
</div>
</div>
{/* Supported Types */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-sm font-bold text-gray-900 mb-4">Asset Types Supported</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Images</h4>
</div>
<p className="text-xs text-gray-500">JPG, PNG, WEBP</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Video className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Videos</h4>
</div>
<p className="text-xs text-gray-500">MP4, MOV</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<FileText className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Documents</h4>
</div>
<p className="text-xs text-gray-500">PDF, DOCX</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<FileBadge className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Certificates</h4>
</div>
<p className="text-xs text-gray-500">ISO, CE, RoHS</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Presentation className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Marketing</h4>
</div>
<p className="text-xs text-gray-500">Banners, Brochures</p>
</div>
<div className="p-4 border border-gray-100 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<Package className="w-5 h-5 text-purple-600" />
<h4 className="font-semibold text-gray-900">Packaging</h4>
</div>
<p className="text-xs text-gray-500">Labels, Artwork</p>
</div>
</div>
</div>
</div>
)}
{(activeTab === "Asset Library" || activeTab === "Asset Relations") && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Layers className="w-12 h-12 text-gray-300 mb-4" />
<h3 className="text-lg font-medium text-gray-900">Coming Soon</h3>
<p className="text-sm text-gray-500 mt-1">This section is currently under development.</p>
</div>
)}
</div>
</div>
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Assets</h1>
<p className="text-sm text-gray-500">Manage your assets</p>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Asset
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
</div>
</PageWrapper>
);
}
+102 -42
View File
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useAsset } from "../hook/useAsset";
import { validateAsset } from "../validation/assets.schema";
import { assetSchema } from "../validation/assets.schema";
import { assetsService } from "../services/assets.service";
import type { AssetCreateRequest } from "../types/assets.types";
import { ArrowLeft, Save } from 'lucide-react';
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewAsset() {
const navigate = useNavigate();
@@ -13,57 +20,110 @@ export default function NewAsset() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useAsset();
const [form, setForm] = useState<Partial<AssetCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active",
},
validationSchema: assetSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as AssetCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
assetsService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateAsset(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as AssetCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Asset</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('..')}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Asset' : 'Create Asset'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update asset details' : 'Add a new asset to your system'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Asset Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Hero Image"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Asset' : 'Create Asset'}
</Button>
</div>
</form>
</PageWrapper>
);
}
@@ -1,6 +1,5 @@
import type { AssetCreateRequest } from '../types/assets.types';
export const validateAsset = (data: Partial<AssetCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Asset name is required';
return errors;
};
import * as Yup from 'yup';
export const assetSchema = Yup.object().shape({
name: Yup.string().required('Asset name is required'),
});
@@ -1,56 +1,151 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Plus, Upload, Download } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Table } from "../../../components/customs/Table";
import { useAttributeGroup } from "../hook/useAttributeGroup";
import type { AttributeGroup } from "../types/attribute-groups.types";
interface AttributeGroup {
id: string;
name: string;
code: string;
description: string;
attributesCount: number;
status: 'active' | 'draft' | 'disabled';
updatedAt: string;
}
const mockGroups: AttributeGroup[] = [
{ id: "1", name: "Basic Information", code: "basic_info", description: "Core product identification attributes", attributesCount: 8, status: "active", updatedAt: "2024-03-15" },
{ id: "2", name: "Pricing & Inventory", code: "pricing_inventory", description: "Commercial and stock management attributes", attributesCount: 12, status: "active", updatedAt: "2024-03-14" },
{ id: "3", name: "Marketing Content", code: "marketing_content", description: "Customer-facing content and SEO attributes", attributesCount: 10, status: "active", updatedAt: "2024-03-12" },
{ id: "4", name: "Technical Specifications", code: "tech_specs", description: "Product technical details and specifications", attributesCount: 16, status: "active", updatedAt: "2024-03-10" },
{ id: "5", name: "Logistics & Shipping", code: "logistics_shipping", description: "Warehouse and fulfillment attributes", attributesCount: 9, status: "draft", updatedAt: "2024-03-08" },
];
export default function AttributeGroupList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useAttributeGroup();
const [selected, setSelected] = useState<Set<string>>(new Set());
const { query, setQuery } = useSearch();
const [statusFilter, setStatusFilter] = useState("All Status");
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: AttributeGroup) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const filteredGroups = mockGroups.filter((group) =>
(group.name.toLowerCase().includes(query.toLowerCase()) ||
group.code.toLowerCase().includes(query.toLowerCase()) ||
group.description.toLowerCase().includes(query.toLowerCase())) &&
(statusFilter === "All Status" || group.status.toLowerCase() === statusFilter.toLowerCase())
);
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
{
key: "name",
label: "GROUP NAME",
sortable: true,
render: (val: string) => <span className="font-semibold text-gray-900">{val}</span>
},
{
key: "code",
label: "CODE",
sortable: true,
render: (val: string) => <span className="font-mono text-sm text-gray-500">{val}</span>
},
{
key: "description",
label: "DESCRIPTION",
render: (val: string) => <span className="text-sm text-gray-600">{val}</span>
},
{
key: "attributesCount",
label: "ATTRIBUTES",
render: (val: number) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-purple-50 text-purple-700">
{val}
</span>
),
},
{
key: "status",
label: "STATUS",
sortable: true,
render: (val: string) => {
let displayStatus: any = "pending";
if (val === "active") displayStatus = "active";
else if (val === "disabled") displayStatus = "disabled";
else if (val === "draft") displayStatus = "warning";
return (
<StatusBadge
status={displayStatus}
label={val.charAt(0).toUpperCase() + val.slice(1)}
/>
);
},
},
{
key: "updatedAt",
label: "UPDATED",
sortable: true,
render: (val: string) => <span className="text-sm text-gray-500">{val}</span>,
},
];
const actions = (item: AttributeGroup) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">AttributeGroups</h1>
<p className="text-sm text-gray-500">Manage your attribute groups</p>
<ProtectedRoute node="products.attributes">
<PageWrapper>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Attribute Groups</h1>
<p className="text-sm text-gray-500 mt-1">
Organize attributes into logical groups for better management
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" icon={<Upload className="w-4 h-4" />}>
Import
</Button>
<Button variant="outline" icon={<Download className="w-4 h-4" />}>
Export
</Button>
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
Create Group
</Button>
</div>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create AttributeGroup
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
</div>
</PageWrapper>
{/* Search & Filter */}
<div className="flex items-center gap-3 mb-6">
<div className="flex-1">
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search groups by name, code, or description..."
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-[42px] px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option>All Status</option>
<option>Active</option>
<option>Draft</option>
<option>Disabled</option>
</select>
</div>
{/* Table */}
<Table<AttributeGroup>
columns={columns}
data={filteredGroups}
/>
{/* Footer Text */}
<div className="mt-4 text-xs text-gray-500 px-1">
Showing <strong>{filteredGroups.length}</strong> of <strong>{mockGroups.length}</strong> attribute groups
</div>
</PageWrapper>
</ProtectedRoute>
);
}
@@ -1,69 +1,131 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { ArrowLeft, Save } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useAttributeGroup } from "../hook/useAttributeGroup";
import { validateAttributeGroup } from "../validation/attribute-groups.schema";
import { attributeGroupsService } from "../services/attribute-groups.service";
import { attributeGroupSchema } from "../validation/attribute-groups.schema";
import type { AttributeGroupCreateRequest } from "../types/attribute-groups.types";
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
];
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewAttributeGroup() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useAttributeGroup();
const [form, setForm] = useState<Partial<AttributeGroupCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const { createItem, updateItem, items, fetchItems } = useAttributeGroup();
useEffect(() => {
if (isEdit && id) {
attributeGroupsService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
});
}
}, [isEdit, id]);
fetchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateAttributeGroup(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as AttributeGroupCreateRequest);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: attributeGroupSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as AttributeGroupCreateRequest);
}
navigate("..");
} catch {
// Handled by service/hook
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id && items.length > 0) {
const match = items.find((item) => item.id === id);
if (match) {
formik.setValues({
name: match.name,
status: match.status as "active" | "inactive",
});
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} AttributeGroup</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
type="button"
onClick={() => navigate("..")}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Attribute Group' : 'Create Attribute Group'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update attribute group details' : 'Add a new attribute group'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Basic Info"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<select
name="status"
className={inputClass()}
value={formik.values.status}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("..")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{formik.isSubmitting ? 'Saving…' : isEdit ? 'Update Attribute Group' : 'Create Attribute Group'}
</Button>
</div>
</form>
</PageWrapper>
);
}
@@ -1,6 +1,6 @@
import type { AttributeGroupCreateRequest } from '../types/attribute-groups.types';
export const validateAttributeGroup = (data: Partial<AttributeGroupCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'AttributeGroup name is required';
return errors;
};
import * as Yup from 'yup';
export const attributeGroupSchema = Yup.object().shape({
name: Yup.string().required('Attribute Group name is required'),
status: Yup.string().oneOf(['active', 'inactive']),
});
@@ -0,0 +1,151 @@
import { useState } from "react";
import { Plus, Upload, Download } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Table } from "../../../components/customs/Table";
interface AttributeGroup {
id: string;
name: string;
code: string;
description: string;
attributesCount: number;
status: 'active' | 'draft' | 'disabled';
updatedAt: string;
}
const mockGroups: AttributeGroup[] = [
{ id: "1", name: "Basic Information", code: "basic_info", description: "Core product identification attributes", attributesCount: 8, status: "active", updatedAt: "2024-03-15" },
{ id: "2", name: "Pricing & Inventory", code: "pricing_inventory", description: "Commercial and stock management attributes", attributesCount: 12, status: "active", updatedAt: "2024-03-14" },
{ id: "3", name: "Marketing Content", code: "marketing_content", description: "Customer-facing content and SEO attributes", attributesCount: 10, status: "active", updatedAt: "2024-03-12" },
{ id: "4", name: "Technical Specifications", code: "tech_specs", description: "Product technical details and specifications", attributesCount: 16, status: "active", updatedAt: "2024-03-10" },
{ id: "5", name: "Logistics & Shipping", code: "logistics_shipping", description: "Warehouse and fulfillment attributes", attributesCount: 9, status: "draft", updatedAt: "2024-03-08" },
];
export default function AttributeGroupList() {
const { query, setQuery } = useSearch();
const [statusFilter, setStatusFilter] = useState("All Status");
const filteredGroups = mockGroups.filter((group) =>
(group.name.toLowerCase().includes(query.toLowerCase()) ||
group.code.toLowerCase().includes(query.toLowerCase()) ||
group.description.toLowerCase().includes(query.toLowerCase())) &&
(statusFilter === "All Status" || group.status.toLowerCase() === statusFilter.toLowerCase())
);
const columns = [
{
key: "name",
label: "GROUP NAME",
sortable: true,
render: (val: string) => <span className="font-semibold text-gray-900">{val}</span>
},
{
key: "code",
label: "CODE",
sortable: true,
render: (val: string) => <span className="font-mono text-sm text-gray-500">{val}</span>
},
{
key: "description",
label: "DESCRIPTION",
render: (val: string) => <span className="text-sm text-gray-600">{val}</span>
},
{
key: "attributesCount",
label: "ATTRIBUTES",
render: (val: number) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-purple-50 text-purple-700">
{val}
</span>
),
},
{
key: "status",
label: "STATUS",
sortable: true,
render: (val: string) => {
let displayStatus: any = "pending";
if (val === "active") displayStatus = "active";
else if (val === "disabled") displayStatus = "disabled";
else if (val === "draft") displayStatus = "warning";
return (
<StatusBadge
status={displayStatus}
label={val.charAt(0).toUpperCase() + val.slice(1)}
/>
);
},
},
{
key: "updatedAt",
label: "UPDATED",
sortable: true,
render: (val: string) => <span className="text-sm text-gray-500">{val}</span>,
},
];
return (
<ProtectedRoute node="products.attributes">
<PageWrapper>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Attribute Groups</h1>
<p className="text-sm text-gray-500 mt-1">
Organize attributes into logical groups for better management
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" icon={<Upload className="w-4 h-4" />}>
Import
</Button>
<Button variant="outline" icon={<Download className="w-4 h-4" />}>
Export
</Button>
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
Create Group
</Button>
</div>
</div>
{/* Search & Filter */}
<div className="flex items-center gap-3 mb-6">
<div className="flex-1">
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search groups by name, code, or description..."
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-[42px] px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option>All Status</option>
<option>Active</option>
<option>Draft</option>
<option>Disabled</option>
</select>
</div>
{/* Table */}
<Table<AttributeGroup>
columns={columns}
data={filteredGroups}
/>
{/* Footer Text */}
<div className="mt-4 text-xs text-gray-500 px-1">
Showing <strong>{filteredGroups.length}</strong> of <strong>{mockGroups.length}</strong> attribute groups
</div>
</PageWrapper>
</ProtectedRoute>
);
}
+159 -44
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { Plus, Tag, Edit2, Trash2 } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Plus, Edit2, Trash2, LayoutGrid, CheckCircle2, Copy, Eye, Grid3x3, Star, Filter, TrendingUp } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -7,9 +7,9 @@ import { Button } from "../../../components/customs/Button";
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Table } from "../../../components/customs/Table";
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
import { useAttribute } from "../hook/useAttribute";
import type { Attribute } from "../types/attribute.types";
import { formatDate } from "../../../utils/formatters";
export default function AttributeList() {
const navigate = useNavigate();
@@ -27,6 +27,13 @@ export default function AttributeList() {
attr.type.toLowerCase().includes(query.toLowerCase())
);
const stats = useMemo(() => {
const active = attributes.filter(a => a.status === 'active').length;
// Mock variant eligible count based on something, or just use 2 as in screenshot
const variantEligible = 2;
return { total: attributes.length, active, variantEligible };
}, [attributes]);
const handleDelete = async (id: string, name: string) => {
if (confirm(`Are you sure you want to delete attribute "${name}"?`)) {
await deleteAttribute(id);
@@ -34,22 +41,81 @@ export default function AttributeList() {
};
const columns = [
{ key: "code", label: "Code", sortable: true },
{ key: "name", label: "Name", sortable: true },
{
key: "code",
label: "ATTRIBUTE CODE",
sortable: true,
render: (val: string) => <span className="font-mono text-xs text-gray-700">{val}</span>
},
{
key: "name",
label: "ATTRIBUTE NAME",
sortable: true,
render: (val: string, row: any) => (
<div className="flex flex-col">
<span className="font-medium text-gray-900">{val}</span>
<span className="text-xs text-gray-500 mt-0.5">
{/* Mock description if not present */}
{row.code === "ATTR_WEIGHT" ? "Product weight in kilograms" :
row.code === "ATTR_COLOR" ? "Product color variant" :
row.code === "ATTR_STORAGE" ? "Device storage capacity" :
"Custom attribute for product data"}
</span>
</div>
)
},
{
key: "type",
label: "Type",
label: "DATA TYPE",
sortable: true,
render: (val: string) => (
<span className="px-2.5 py-1 text-xs font-semibold rounded-md bg-purple-50 text-purple-700 capitalize border border-purple-100">
{val}
</span>
),
render: (val: string) => {
let colorClass = "bg-purple-50 text-purple-700 border-purple-200";
if (val === "number" || val === "decimal") {
colorClass = "bg-green-50 text-green-700 border-green-200";
val = "Decimal";
} else if (val === "select") {
val = "Select";
} else {
val = val.charAt(0).toUpperCase() + val.slice(1);
}
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded border ${colorClass}`}>
{val}
</span>
);
},
},
{
key: "isRequired",
label: "REQUIRED",
render: (val: boolean) => val ? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>,
},
{
key: "variantEligible",
label: "VARIANT ELIGIBLE",
render: (_: any, row: any) => {
// Mock variant eligible
const isEligible = row.code === "ATTR_COLOR" || row.code === "ATTR_STORAGE";
return isEligible ? <CheckCircle2 className="w-4 h-4 text-purple-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>;
},
},
{
key: "searchable",
label: "SEARCHABLE",
render: (_: any, row: any) => {
// Mock searchable
const isSearchable = row.code === "ATTR_COLOR" || row.code === "ATTR_STORAGE";
return isSearchable ? <CheckCircle2 className="w-4 h-4 text-blue-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>;
},
},
{
key: "isLocalizable",
label: "LOCALIZABLE",
render: (val: boolean) => val ? <CheckCircle2 className="w-4 h-4 text-amber-500 mx-auto" /> : <span className="text-gray-300 mx-auto"></span>,
},
{ key: "group", label: "Group", sortable: true },
{
key: "status",
label: "Status",
label: "STATUS",
sortable: true,
render: (val: string) => (
<StatusBadge
@@ -59,14 +125,38 @@ export default function AttributeList() {
),
},
{
key: "isRequired",
label: "Required",
render: (val: boolean) => (val ? "Yes" : "No"),
key: "usageCount",
label: "USAGE COUNT",
render: (_: any, row: any) => {
// Mock usages
let count = "0", cats = "0", fams = "0", prods = "0";
if (row.code === "ATTR_WEIGHT") { count = "2,915"; cats = "45"; fams = "23"; prods = "2847"; }
else if (row.code === "ATTR_COLOR") { count = "5,718"; cats = "12"; fams = "34"; prods = "5672"; }
else if (row.code === "ATTR_STORAGE") { count = "1,266"; cats = "8"; fams = "15"; prods = "1243"; }
return count !== "0" ? (
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{count}</span>
<span className="text-[10px] text-gray-500 leading-tight mt-0.5">
{cats} cats, {fams} fams, {prods} prods
</span>
</div>
) : <span className="text-gray-300"></span>;
},
},
{
key: "lastUpdated",
label: "Last Updated",
render: (val: string) => formatDate(val),
label: "LAST UPDATED",
render: (val: string, row: any) => {
const dateStr = val ? new Date(val).toISOString().split('T')[0] : "2024-03-10";
const author = row.code === "ATTR_WEIGHT" ? "Sarah Chen" : row.code === "ATTR_COLOR" ? "Emily Rodriguez" : "John Doe";
return (
<div className="flex flex-col">
<span className="text-xs text-gray-900">{dateStr}</span>
<span className="text-[10px] text-gray-500 mt-0.5">by {author}</span>
</div>
);
},
},
];
@@ -76,14 +166,9 @@ export default function AttributeList() {
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
<div>
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
<Tag className="w-5 h-5" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Attributes</h1>
</div>
<h1 className="text-2xl font-bold text-gray-900">Attribute Registry</h1>
<p className="text-sm text-gray-500 mt-1">
Manage product custom fields, attributes, and options
Central repository of reusable product attributes for categories, families, products, and channels
</p>
</div>
@@ -98,13 +183,51 @@ export default function AttributeList() {
</div>
</div>
{/* KPI Cards */}
<InfoCardGrid cols={4} className="mb-6">
<InfoCard
label="Total Attributes"
value={stats.total}
icon={<Grid3x3 className="w-5 h-5" />}
variant="blue"
subtitle={<span className="text-green-600 flex items-center gap-1 text-xs"><TrendingUp className="w-3 h-3"/> 12% this month</span>}
/>
<InfoCard
label="Active Attributes"
value={stats.active}
icon={<CheckCircle2 className="w-5 h-5 text-green-500" />}
variant="green"
subtitle={`${stats.total > 0 ? Math.round((stats.active/stats.total)*100) : 0}% of total`}
/>
<InfoCard
label="Variant Eligible"
value={stats.variantEligible}
icon={<LayoutGrid className="w-5 h-5" />}
variant="purple"
subtitle="Used for SKU generation"
/>
<InfoCard
label="Most Used"
value="Brand"
icon={<Star className="w-5 h-5 text-orange-400" />}
variant="amber"
subtitle="10,089 usages"
/>
</InfoCardGrid>
{/* Search */}
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search attributes by code, name, group, or type..."
className="mb-6"
/>
<div className="flex items-center gap-3 mb-6">
<div className="flex-1">
<SearchBar
value={query}
onChange={setQuery}
placeholder="Search by attribute name, code, or description..."
/>
</div>
<Button variant="outline" icon={<Filter className="w-4 h-4" />}>
Filters
</Button>
</div>
{/* Table */}
<Table<Attribute>
@@ -112,19 +235,11 @@ export default function AttributeList() {
data={filteredAttributes}
onRowClick={(row) => navigate(`/attributes/${row.id}/edit`)}
actions={(row) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
icon={<Edit2 className="w-4 h-4" />}
onClick={() => navigate(`/attributes/${row.id}/edit`)}
/>
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
onClick={() => handleDelete(row.id, row.name)}
/>
<div className="flex items-center justify-end gap-2 text-gray-400">
<Eye className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Edit2 className="w-4 h-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={(e) => { e.stopPropagation(); navigate(`/attributes/${row.id}/edit`); }} />
<Copy className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); handleDelete(row.id, row.name); }} />
</div>
)}
/>
+216 -226
View File
@@ -1,13 +1,19 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { ArrowLeft, Tag } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useAttribute } from "../hook/useAttribute";
import { validateAttribute } from "../validation/attribute.schema";
import { attributeSchema } from "../validation/attribute.schema";
import type { AttributeCreateRequest } from "../types/attribute.types";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewAttribute() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
@@ -15,21 +21,53 @@ export default function NewAttribute() {
const { createAttribute, updateAttribute, fetchAttributes, attributes } = useAttribute();
const [form, setForm] = useState<Partial<AttributeCreateRequest>>({
code: "",
name: "",
type: "text",
group: "General",
isRequired: false,
isUnique: false,
isLocalizable: false,
options: [],
status: "active",
});
const formik = useFormik({
initialValues: {
code: "",
name: "",
type: "text",
group: "General",
isRequired: false,
isUnique: false,
isLocalizable: false,
optionsText: "",
status: "active" as "active" | "inactive",
},
validationSchema: attributeSchema,
onSubmit: async (values, { setSubmitting }) => {
const parsedOptions = values.optionsText
? values.optionsText
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "")
: [];
const [optionsText, setOptionsText] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const payload = {
code: values.code,
name: values.name,
type: values.type as any,
group: values.group,
isRequired: values.isRequired,
isUnique: values.isUnique,
isLocalizable: values.isLocalizable,
options: parsedOptions,
status: values.status,
};
try {
if (isEdit && id) {
await updateAttribute(id, payload);
} else {
await createAttribute(payload as AttributeCreateRequest);
}
navigate("/attributes");
} catch {
// hook handles toast
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
fetchAttributes();
@@ -39,7 +77,7 @@ export default function NewAttribute() {
if (isEdit && attributes.length > 0) {
const match = attributes.find((item) => item.id === id);
if (match) {
setForm({
formik.setValues({
code: match.code,
name: match.name,
type: match.type,
@@ -47,256 +85,208 @@ export default function NewAttribute() {
isRequired: match.isRequired,
isUnique: match.isUnique,
isLocalizable: match.isLocalizable,
options: match.options || [],
optionsText: match.options ? match.options.join(", ") : "",
status: match.status,
});
if (match.options) {
setOptionsText(match.options.join(", "));
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, attributes]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
if (type === "checkbox") {
const checked = (e.target as HTMLInputElement).checked;
setForm((prev) => ({ ...prev, [name]: checked }));
} else {
setForm((prev) => ({ ...prev, [name]: value }));
}
};
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setOptionsText(val);
const parsedOptions = val
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "");
setForm((prev) => ({ ...prev, options: parsedOptions }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateAttribute(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateAttribute(id, form);
} else {
await createAttribute(form as AttributeCreateRequest);
}
navigate("/attributes");
} catch {
// hook handles toast
} finally {
setSaving(false);
}
};
return (
<ProtectedRoute node="products.attributes">
<PageWrapper>
{/* Back and Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate("/attributes")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
Back to Attributes
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
<Tag className="w-5 h-5" />
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
<Tag className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? "Edit Attribute" : "Create Attribute"}
</h1>
<p className="text-sm text-gray-500 mt-1">
{isEdit ? "Update attribute details" : "Add a new attribute to describe your products"}
</p>
</div>
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? "Edit Attribute" : "Create Attribute"}
</h1>
</div>
</div>
{/* Form Container */}
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-2xl shadow-sm">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Code */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Attribute Code <span className="text-red-500">*</span>
</label>
<input
type="text"
name="code"
value={form.code}
onChange={handleChange}
disabled={isEdit}
placeholder="e.g. fabric_type"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
} disabled:bg-gray-50 disabled:text-gray-400`}
/>
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
</div>
{/* Name */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Attribute Name <span className="text-red-500">*</span>
</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
placeholder="e.g. Fabric Type"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
}`}
/>
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
</div>
{/* Group */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Attribute Group <span className="text-red-500">*</span>
</label>
<input
type="text"
name="group"
value={form.group}
onChange={handleChange}
placeholder="e.g. General, Technical, Dimensions"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.group ? "border-red-400 focus:ring-red-100" : "border-gray-300"
}`}
/>
{errors.group && <p className="text-xs text-red-500 mt-1">{errors.group}</p>}
</div>
{/* Type */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Attribute Type <span className="text-red-500">*</span>
</label>
<select
name="type"
value={form.type}
onChange={handleChange}
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all bg-white"
>
<option value="text">Text (Single-line)</option>
<option value="textarea">Textarea (Multi-line)</option>
<option value="number">Number</option>
<option value="boolean">Boolean (Yes/No)</option>
<option value="select">Select (Single choice)</option>
<option value="multiselect">Multi-select (Multiple choices)</option>
<option value="image">Image Upload</option>
</select>
</div>
{/* Select Options */}
{(form.type === "select" || form.type === "multiselect") && (
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Attribute Settings</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Code */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Options (Comma separated) <span className="text-red-500">*</span>
</label>
<label className={labelClass}>Attribute Code *</label>
<input
type="text"
value={optionsText}
onChange={handleOptionsChange}
placeholder="e.g. Small, Medium, Large"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.options ? "border-red-400 focus:ring-red-100" : "border-gray-300"
}`}
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g. fabric_type"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-gray-50 disabled:text-gray-400`}
/>
<p className="text-xs text-gray-400 mt-1">Separate values using commas.</p>
{errors.options && <p className="text-xs text-red-500 mt-1">{errors.options}</p>}
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
)}
{/* Flags */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
{/* Name */}
<div>
<label className={labelClass}>Attribute Name *</label>
<input
type="checkbox"
name="isRequired"
checked={form.isRequired}
onChange={handleChange}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
type="text"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Fabric Type"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
<span className="text-sm font-medium text-gray-700">Required</span>
</label>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
{/* Group */}
<div>
<label className={labelClass}>Attribute Group *</label>
<input
type="checkbox"
name="isUnique"
checked={form.isUnique}
onChange={handleChange}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
type="text"
name="group"
value={formik.values.group}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. General, Technical, Dimensions"
className={inputClass(formik.touched.group && Boolean(formik.errors.group))}
/>
<span className="text-sm font-medium text-gray-700">Unique Value</span>
</label>
{formik.touched.group && formik.errors.group && <p className={errorClass}>{formik.errors.group}</p>}
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
name="isLocalizable"
checked={form.isLocalizable}
onChange={handleChange}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm font-medium text-gray-700">Localizable</span>
</label>
</div>
{/* Type */}
<div>
<label className={labelClass}>Attribute Type *</label>
<select
name="type"
value={formik.values.type}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.type && Boolean(formik.errors.type))}
>
<option value="text">Text (Single-line)</option>
<option value="textarea">Textarea (Multi-line)</option>
<option value="number">Number</option>
<option value="boolean">Boolean (Yes/No)</option>
<option value="select">Select (Single choice)</option>
<option value="multiselect">Multi-select (Multiple choices)</option>
<option value="image">Image Upload</option>
</select>
{formik.touched.type && formik.errors.type && <p className={errorClass}>{formik.errors.type}</p>}
</div>
{/* Status */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
{/* Select Options */}
{(formik.values.type === "select" || formik.values.type === "multiselect") && (
<div className="md:col-span-2">
<label className={labelClass}>Options (Comma separated) *</label>
<input
type="radio"
name="status"
value="active"
checked={form.status === "active"}
onChange={handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
type="text"
name="optionsText"
value={formik.values.optionsText}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Small, Medium, Large"
className={inputClass(formik.touched.optionsText && Boolean(formik.errors.optionsText))}
/>
<span className="text-sm text-gray-700">Active</span>
<p className="text-xs text-gray-400 mt-1">Separate values using commas.</p>
{formik.touched.optionsText && formik.errors.optionsText && <p className={errorClass}>{formik.errors.optionsText}</p>}
</div>
)}
{/* Flags */}
<div className="md:col-span-2 grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
name="isRequired"
checked={formik.values.isRequired}
onChange={formik.handleChange}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm font-medium text-gray-700">Required</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="radio"
name="status"
value="inactive"
checked={form.status === "inactive"}
onChange={handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
type="checkbox"
name="isUnique"
checked={formik.values.isUnique}
onChange={formik.handleChange}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700">Inactive</span>
<span className="text-sm font-medium text-gray-700">Unique Value</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
name="isLocalizable"
checked={formik.values.isLocalizable}
onChange={formik.handleChange}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm font-medium text-gray-700">Localizable</span>
</label>
</div>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
<Button variant="outline" type="button" onClick={() => navigate("/attributes")} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={saving}>
{isEdit ? "Save Changes" : "Create Attribute"}
</Button>
{/* Status */}
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</form>
</div>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("/attributes")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" loading={formik.isSubmitting}>
{isEdit ? "Save Changes" : "Create Attribute"}
</Button>
</div>
</form>
</PageWrapper>
</ProtectedRoute>
);
@@ -1,11 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import AttributeList from '../pages/AttributeList';
import NewAttribute from '../pages/NewAttribute';
import AttributeGroupList from '../../attribute-groups/pages/AttributeGroupList';
export const AttributeRoutes = () => {
return (
<Routes>
<Route index element={<AttributeList />} />
<Route path="groups" element={<AttributeGroupList />} />
<Route path="new" element={<NewAttribute />} />
<Route path=":id/edit" element={<NewAttribute />} />
</Routes>
@@ -1,29 +1,19 @@
import type { AttributeCreateRequest } from '../types/attribute.types';
import * as Yup from 'yup';
export const validateAttribute = (data: Partial<AttributeCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.code?.trim()) {
errors.code = 'Attribute code is required';
} else if (!/^[a-z0-9_]+$/.test(data.code)) {
errors.code = 'Code can only contain lowercase letters, numbers, and underscores';
}
if (!data.name?.trim()) {
errors.name = 'Attribute name is required';
}
if (!data.group?.trim()) {
errors.group = 'Attribute group is required';
}
if (!data.type) {
errors.type = 'Attribute type is required';
}
if ((data.type === 'select' || data.type === 'multiselect') && (!data.options || data.options.length === 0)) {
errors.options = 'At least one option is required for selectable types';
}
return errors;
};
export const attributeSchema = Yup.object().shape({
code: Yup.string()
.required('Attribute code is required')
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores'),
name: Yup.string().required('Attribute name is required'),
group: Yup.string().required('Attribute group is required'),
type: Yup.string().required('Attribute type is required'),
optionsText: Yup.string().when('type', {
is: (val: string) => val === 'select' || val === 'multiselect',
then: (schema) => schema.required('At least one option is required for selectable types'),
otherwise: (schema) => schema.notRequired(),
}),
isRequired: Yup.boolean(),
isUnique: Yup.boolean(),
isLocalizable: Yup.boolean(),
status: Yup.string().oneOf(['active', 'inactive']),
});
+1 -1
View File
@@ -1,5 +1,5 @@
export { default as BrandList } from './pages/BrandList';
export { default as NewBrand } from './pages/NewBrand';
export { default as NewBrandForm } from './pages/NewBrandForm';
export { default as BrandRoutes } from './routes/brand.routes';
export * from './types/brand.types';
export * from './hook/useBrand';
+140 -150
View File
@@ -1,5 +1,7 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import { ArrowLeft, Award } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -7,6 +9,20 @@ import { Button } from "../../../components/customs/Button";
import { useBrand } from "../hook/useBrand";
import type { BrandCreateRequest } from "../types/brand.types";
const brandSchema = Yup.object().shape({
code: Yup.string()
.required("Brand code is required")
.matches(/^[a-z0-9_]+$/, "Code can only contain lowercase letters, numbers, and underscores"),
name: Yup.string().required("Brand name is required"),
description: Yup.string(),
status: Yup.string().oneOf(["active", "inactive"]),
});
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewBrand() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
@@ -14,16 +30,30 @@ export default function NewBrand() {
const { createBrand, updateBrand, fetchBrands, brands } = useBrand();
const [form, setForm] = useState<Partial<BrandCreateRequest>>({
code: "",
name: "",
description: "",
status: "active",
const formik = useFormik({
initialValues: {
code: "",
name: "",
description: "",
status: "active" as "active" | "inactive",
},
validationSchema: brandSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateBrand(id, values);
} else {
await createBrand(values as BrandCreateRequest);
}
navigate("/brands");
} catch {
// hook handles toast
} finally {
setSubmitting(false);
}
},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchBrands();
}, [fetchBrands]);
@@ -32,7 +62,7 @@ export default function NewBrand() {
if (isEdit && brands.length > 0) {
const match = brands.find((item) => item.id === id);
if (match) {
setForm({
formik.setValues({
code: match.code,
name: match.name,
description: match.description || "",
@@ -40,167 +70,127 @@ export default function NewBrand() {
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, brands]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, status: e.target.value as any }));
};
const validate = (): boolean => {
const tempErrors: Record<string, string> = {};
if (!form.code?.trim()) {
tempErrors.code = "Brand code is required";
} else if (!/^[a-z0-9_]+$/.test(form.code)) {
tempErrors.code = "Code can only contain lowercase letters, numbers, and underscores";
}
if (!form.name?.trim()) {
tempErrors.name = "Brand name is required";
}
setErrors(tempErrors);
return Object.keys(tempErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setSaving(true);
try {
if (isEdit && id) {
await updateBrand(id, form);
} else {
await createBrand(form as BrandCreateRequest);
}
navigate("/brands");
} catch {
// hook handles toast
} finally {
setSaving(false);
}
};
return (
<ProtectedRoute node="masters.brands">
<PageWrapper>
{/* Back and Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate("/brands")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
Back to Brands
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
<Award className="w-5 h-5" />
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
<Award className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? "Edit Brand" : "Create Brand"}
</h1>
<p className="text-sm text-gray-500 mt-1">
{isEdit ? "Update brand details" : "Add a new brand to the system"}
</p>
</div>
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? "Edit Brand" : "Create Brand"}
</h1>
</div>
</div>
{/* Form Container */}
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-2xl shadow-sm">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Code */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Brand Code <span className="text-red-500">*</span>
</label>
<input
type="text"
name="code"
value={form.code}
onChange={handleChange}
disabled={isEdit}
placeholder="e.g. nike"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
} disabled:bg-gray-50 disabled:text-gray-400`}
/>
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Brand Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Code */}
<div>
<label className={labelClass}>Brand Code *</label>
<input
type="text"
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g. nike"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-gray-50 disabled:text-gray-400`}
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
{/* Name */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Brand Name <span className="text-red-500">*</span>
</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
placeholder="e.g. Nike"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
}`}
/>
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
</div>
{/* Name */}
<div>
<label className={labelClass}>Brand Name *</label>
<input
type="text"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Nike"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
{/* Description */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Description</label>
<textarea
name="description"
value={form.description}
onChange={handleChange}
placeholder="e.g. Athletic apparel and accessories manufacturer"
rows={3}
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all"
/>
</div>
{/* Description */}
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Athletic apparel and accessories manufacturer"
rows={3}
className={inputClass()}
/>
</div>
{/* Status */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={form.status === "active"}
onChange={handleStatusChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700">Active</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={form.status === "inactive"}
onChange={handleStatusChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700">Inactive</span>
</label>
{/* Status */}
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
<Button variant="outline" type="button" onClick={() => navigate("/brands")} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={saving}>
{isEdit ? "Save Changes" : "Create Brand"}
</Button>
</div>
</form>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("/brands")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" loading={formik.isSubmitting}>
{isEdit ? "Save Changes" : "Create Brand"}
</Button>
</div>
</form>
</PageWrapper>
</ProtectedRoute>
);
-5
View File
@@ -7,9 +7,4 @@ export * from './validation/category.schema';
export * from './services/category.service';
export * from './api/category.api';
export { default as CategoryTree } from './components/CategoryTree';
export { default as useCategoryHook } from './hook/useCategory';
export { default as categoryRoutes } from './routes/category.routes';
export { default as categorySchema } from './validation/category.schema';
export { default as categoryTypes } from './types/category.types';
export { default as categoryAPI } from './api/category.api';
export { default as categoryServiceFile } from './services/category.service';
+55 -25
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { Plus, FolderTree, Edit2, Trash2 } from "lucide-react";
import { Plus, FolderTree, Edit2, Trash2, Eye, Grid3x3, Layers, Package, LayoutGrid } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -7,9 +7,9 @@ import { Button } from "../../../components/customs/Button";
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Table } from "../../../components/customs/Table";
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
import { useCategory } from "../hook/useCategory";
import type { Category } from "../types/category.types";
import { formatDate } from "../../../utils/formatters";
export default function CategoryList() {
const navigate = useNavigate();
@@ -20,6 +20,8 @@ export default function CategoryList() {
fetchCategories();
}, [fetchCategories]);
const stats = { total: 9, active: 8, products: 33008, families: 406 };
const filteredCategories = categories.filter((cat) =>
cat.name.toLowerCase().includes(query.toLowerCase()) ||
cat.code.toLowerCase().includes(query.toLowerCase()) ||
@@ -34,13 +36,27 @@ export default function CategoryList() {
};
const columns = [
{ key: "code", label: "Code", sortable: true },
{ key: "name", label: "Category Name", sortable: true },
{ key: "parentName", label: "Parent Category", sortable: true, render: (val: string) => val || "—" },
{ key: "description", label: "Description", sortable: true },
{
key: "name",
label: "CATEGORY NAME",
sortable: true,
render: (val: string, row: any) => (
<div className="flex flex-col">
<span className="font-medium text-gray-900">{val}</span>
<span className="text-xs text-gray-500 mt-0.5">{row.description || "Category description"}</span>
</div>
)
},
{ key: "code", label: "CODE", sortable: true, render: (val: string) => <span className="font-mono text-xs text-gray-700">{val}</span> },
{ key: "parentName", label: "PARENT", sortable: true, render: (val: string) => <span className="text-sm text-gray-700">{val || "—"}</span> },
{ key: "level", label: "LEVEL", render: (_: any, row: any) => <span className="text-sm font-medium text-gray-900">{row.parentName ? (row.parentName === "Electronics" ? 2 : 3) : 1}</span> },
{ key: "attrGroups", label: "ATTR GROUPS", render: () => <span className="text-sm font-medium text-gray-900">8</span> },
{ key: "products", label: "PRODUCTS", render: () => <span className="text-sm font-medium text-gray-900">12,483</span> },
{ key: "families", label: "FAMILIES", render: () => <span className="text-sm font-medium text-gray-900">156</span> },
{ key: "children", label: "CHILDREN", render: () => <span className="text-sm font-medium text-gray-900">5</span> },
{
key: "status",
label: "Status",
label: "STATUS",
sortable: true,
render: (val: string) => (
<StatusBadge
@@ -49,11 +65,6 @@ export default function CategoryList() {
/>
),
},
{
key: "lastUpdated",
label: "Last Updated",
render: (val: string) => formatDate(val),
},
];
return (
@@ -84,6 +95,34 @@ export default function CategoryList() {
</div>
</div>
{/* KPI Cards */}
<InfoCardGrid cols={4} className="mb-6">
<InfoCard
label="Total Categories"
value={stats.total}
icon={<Grid3x3 className="w-5 h-5 text-purple-600" />}
variant="purple"
/>
<InfoCard
label="Active Categories"
value={stats.active}
icon={<Layers className="w-5 h-5 text-green-600" />}
variant="green"
/>
<InfoCard
label="Total Products"
value={stats.products.toLocaleString()}
icon={<Package className="w-5 h-5 text-blue-600" />}
variant="blue"
/>
<InfoCard
label="Product Families"
value={stats.families}
icon={<LayoutGrid className="w-5 h-5 text-orange-400" />}
variant="amber"
/>
</InfoCardGrid>
{/* Search */}
<SearchBar
value={query}
@@ -98,19 +137,10 @@ export default function CategoryList() {
data={filteredCategories}
onRowClick={(row) => navigate(`/categories/${row.id}/edit`)}
actions={(row) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
icon={<Edit2 className="w-4 h-4" />}
onClick={() => navigate(`/categories/${row.id}/edit`)}
/>
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
onClick={() => handleDelete(row.id, row.name)}
/>
<div className="flex items-center justify-end gap-3 text-gray-400 px-2">
<Eye className="w-4 h-4 cursor-pointer hover:text-gray-700 transition-colors" />
<Edit2 className="w-4 h-4 cursor-pointer hover:text-blue-600 transition-colors" onClick={(e) => { e.stopPropagation(); navigate(`/categories/${row.id}/edit`); }} />
<Trash2 className="w-4 h-4 cursor-pointer hover:text-red-500 transition-colors" onClick={(e) => { e.stopPropagation(); handleDelete(row.id, row.name); }} />
</div>
)}
/>
+150 -157
View File
@@ -1,13 +1,19 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { ArrowLeft, FolderTree } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useCategory } from "../hook/useCategory";
import { validateCategory } from "../validation/category.schema";
import { categorySchema } from "../validation/category.schema";
import type { CategoryCreateRequest } from "../types/category.types";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewCategory() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
@@ -15,17 +21,31 @@ export default function NewCategory() {
const { createCategory, updateCategory, fetchCategories, categories } = useCategory();
const [form, setForm] = useState<Partial<CategoryCreateRequest>>({
code: "",
name: "",
parentId: "",
description: "",
status: "active",
const formik = useFormik({
initialValues: {
code: "",
name: "",
parentId: "",
description: "",
status: "active" as "active" | "inactive",
},
validationSchema: categorySchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateCategory(id, values);
} else {
await createCategory(values as CategoryCreateRequest);
}
navigate("/categories");
} catch {
// hook handles toast
} finally {
setSubmitting(false);
}
},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
@@ -34,7 +54,7 @@ export default function NewCategory() {
if (isEdit && categories.length > 0) {
const match = categories.find((item) => item.id === id);
if (match) {
setForm({
formik.setValues({
code: match.code,
name: match.name,
parentId: match.parentId || "",
@@ -43,40 +63,9 @@ export default function NewCategory() {
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, categories]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, status: e.target.value as any }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateCategory(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateCategory(id, form);
} else {
await createCategory(form as CategoryCreateRequest);
}
navigate("/categories");
} catch {
// hook handles toast
} finally {
setSaving(false);
}
};
// Filter out the current category from the parent choices to prevent self-reference
const parentChoices = categories.filter((c) => c.status === "active" && (!isEdit || c.id !== id));
@@ -84,135 +73,139 @@ export default function NewCategory() {
<ProtectedRoute node="products.categories">
<PageWrapper>
{/* Back and Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate("/categories")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
Back to Categories
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
<FolderTree className="w-5 h-5" />
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
<FolderTree className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? "Edit Category" : "Create Category"}
</h1>
<p className="text-sm text-gray-500 mt-1">
{isEdit ? "Update category details" : "Add a new category to organize products"}
</p>
</div>
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? "Edit Category" : "Create Category"}
</h1>
</div>
</div>
{/* Form Container */}
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-2xl shadow-sm">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Code */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Category Code <span className="text-red-500">*</span>
</label>
<input
type="text"
name="code"
value={form.code}
onChange={handleChange}
disabled={isEdit}
placeholder="e.g. electronics"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
} disabled:bg-gray-50 disabled:text-gray-400`}
/>
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Category Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Code */}
<div>
<label className={labelClass}>Category Code *</label>
<input
type="text"
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g. electronics"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-gray-50 disabled:text-gray-400`}
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
{/* Name */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Category Name <span className="text-red-500">*</span>
</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
placeholder="e.g. Electronics"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
}`}
/>
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
</div>
{/* Name */}
<div>
<label className={labelClass}>Category Name *</label>
<input
type="text"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Electronics"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
{/* Parent Category */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Parent Category</label>
<select
name="parentId"
value={form.parentId}
onChange={handleChange}
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all bg-white"
>
<option value="">None (Top-level Category)</option>
{parentChoices.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName ? `${c.parentName} > ${c.name}` : c.name}
</option>
))}
</select>
</div>
{/* Parent Category */}
<div className="md:col-span-2">
<label className={labelClass}>Parent Category</label>
<select
name="parentId"
value={formik.values.parentId}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass()}
>
<option value="">None (Top-level Category)</option>
{parentChoices.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName ? `${c.parentName} > ${c.name}` : c.name}
</option>
))}
</select>
</div>
{/* Description */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Description</label>
<textarea
name="description"
value={form.description}
onChange={handleChange}
placeholder="e.g. Consumer electronics products and accessories"
rows={3}
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all"
/>
</div>
{/* Description */}
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Consumer electronics products and accessories"
rows={3}
className={inputClass()}
/>
</div>
{/* Status */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={form.status === "active"}
onChange={handleStatusChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700">Active</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={form.status === "inactive"}
onChange={handleStatusChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700">Inactive</span>
</label>
{/* Status */}
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
<Button variant="outline" type="button" onClick={() => navigate("/categories")} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={saving}>
{isEdit ? "Save Changes" : "Create Category"}
</Button>
</div>
</form>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate("/categories")} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" loading={formik.isSubmitting}>
{isEdit ? "Save Changes" : "Create Category"}
</Button>
</div>
</form>
</PageWrapper>
</ProtectedRoute>
);
@@ -1,17 +1,11 @@
import type { CategoryCreateRequest } from '../types/category.types';
import * as Yup from 'yup';
export const validateCategory = (data: Partial<CategoryCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.code?.trim()) {
errors.code = 'Category code is required';
} else if (!/^[a-z0-9_]+$/.test(data.code)) {
errors.code = 'Code can only contain lowercase letters, numbers, and underscores';
}
if (!data.name?.trim()) {
errors.name = 'Category name is required';
}
return errors;
};
export const categorySchema = Yup.object().shape({
code: Yup.string()
.required('Category code is required')
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores'),
name: Yup.string().required('Category name is required'),
parentId: Yup.string(),
description: Yup.string(),
status: Yup.string().oneOf(['active', 'inactive']),
});
+134 -36
View File
@@ -1,55 +1,153 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Plus, Edit2, Trash2, Eye, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Globe, Smartphone, Monitor } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useChannel } from "../hook/useChannel";
import type { Channel } from "../types/channels.types";
import { KPI, KPIGrid } from "../../../components/customs/KPI";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { SearchBar } from "../../../components/customs/SearchBar";
// Mock Data
const MOCK_CHANNELS = [
{ id: "1", name: "Shopify Storefront", desc: "Primary ecommerce storefront powered by Shopify", code: "shopify_main", type: "Ecommerce", status: "active", families: 24, products: "8,942", updated: "2025-03-15", author: "Sarah Chen", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" },
{ id: "2", name: "Amazon Marketplace", desc: "Amazon.com US marketplace listing channel", code: "amazon_us", type: "Marketplace", status: "active", families: 18, products: "6,241", updated: "2025-03-12", author: "Michael Torres", icon: ShoppingBag, typeColor: "text-amber-600", typeBg: "bg-amber-50" },
{ id: "3", name: "WooCommerce Store", desc: "European WooCommerce store for EU market", code: "woocommerce_eu", type: "Ecommerce", status: "active", families: 15, products: "4,312", updated: "2025-02-28", author: "Emma Wilson", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" },
{ id: "4", name: "Company Website", desc: "Corporate marketing website product catalog", code: "website_main", type: "Website", status: "active", families: 30, products: "12,840", updated: "2025-03-14", author: "Admin User", icon: Globe, typeColor: "text-purple-600", typeBg: "bg-purple-50" },
{ id: "5", name: "Retail POS", desc: "In-store point-of-sale terminals", code: "pos_retail", type: "POS", status: "active", families: 20, products: "9,840", updated: "2025-03-10", author: "James Park", icon: Smartphone, typeColor: "text-emerald-600", typeBg: "bg-emerald-50" },
{ id: "6", name: "SAP ERP System", desc: "SAP S/4HANA enterprise resource planning integration", code: "sap_erp", type: "ERP", status: "active", families: 28, products: "11,200", updated: "2025-03-05", author: "Tech Team", icon: Monitor, typeColor: "text-red-600", typeBg: "bg-red-50" },
];
export default function ChannelList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useChannel();
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Channel) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const [searchQuery, setSearchQuery] = useState("");
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
{
key: "name",
label: "Channel Name",
sortable: true,
render: (_: any, row: any) => {
const Icon = row.icon;
return (
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center shrink-0 ${row.typeBg}`}>
<Icon className={`w-4 h-4 ${row.typeColor}`} />
</div>
<div>
<div className="font-medium text-gray-900">{row.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.desc}</div>
</div>
</div>
);
},
},
{
key: "code",
label: "Code",
render: (val: string) => <span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{val}</span>,
},
{
key: "type",
label: "Type",
render: (val: string, row: any) => {
const Icon = row.icon;
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border border-transparent ${row.typeBg} ${row.typeColor}`}>
<Icon className="w-3 h-3" />
{val}
</div>
);
},
},
{
key: "status",
label: "Status",
render: (val: any) => <StatusBadge status={val} />,
},
{ key: "families", label: "Families" },
{ key: "products", label: "Products" },
{
key: "updated",
label: "Updated",
render: (_: any, row: any) => (
<div>
<div className="text-gray-900 font-medium text-sm">{row.updated}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.author}</div>
</div>
),
},
];
const actions = (item: Channel) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
const actions = () => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-red-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Trash2 className="w-4 h-4" /></button>
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Channels</h1>
<p className="text-sm text-gray-500">Manage your channels</p>
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-purple-50 flex items-center justify-center shrink-0">
<Radio className="w-6 h-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Channel Master</h1>
<p className="text-sm text-gray-500">Manage publication destinations for product information</p>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
<Plus className="w-4 h-4 mr-2" />
Add Channel
</Button>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Channel
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* Stats Cards */}
<KPIGrid className="mb-6">
<KPI value="9" label="TOTAL CHANNELS" icon={Radio} variant="purple" />
<KPI value="8" label="ACTIVE CHANNELS" icon={CheckCircle} variant="green" />
<KPI value="183" label="FAMILIES ASSIGNED" icon={Layers} variant="blue" />
<KPI value="69,905" label="PRODUCTS PUBLISHED" icon={TrendingUp} variant="amber" />
</KPIGrid>
{/* Main Content Area */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col gap-4 p-4">
{/* Toolbar */}
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<div className="w-64">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search channels..." />
</div>
<select className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white">
<option>All Statuses</option>
<option>Active</option>
<option>Inactive</option>
</select>
<select className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white">
<option>All Types</option>
<option>Ecommerce</option>
<option>Marketplace</option>
</select>
</div>
<div className="text-sm text-gray-500">
9 of 9 channels
</div>
</div>
{/* Table */}
<Table
columns={columns}
data={MOCK_CHANNELS}
actions={actions}
/>
</div>
</PageWrapper>
);
+303 -51
View File
@@ -1,69 +1,321 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
ShoppingCart, Store, Server, Warehouse, CreditCard,
Building2, Smartphone, Globe, Upload, Zap
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { useChannel } from "../hook/useChannel";
import { validateChannel } from "../validation/channels.schema";
import { channelsService } from "../services/channels.service";
import type { ChannelCreateRequest } from "../types/channels.types";
const CHANNEL_TYPES = [
{ id: "ecommerce", label: "Ecommerce", icon: ShoppingCart, color: "text-blue-500", bg: "bg-blue-50" },
{ id: "marketplace", label: "Marketplace", icon: Store, color: "text-orange-500", bg: "bg-orange-50" },
{ id: "erp", label: "ERP", icon: Server, color: "text-red-500", bg: "bg-red-50" },
{ id: "wms", label: "WMS", icon: Warehouse, color: "text-yellow-600", bg: "bg-yellow-50" },
{ id: "pos", label: "POS", icon: CreditCard, color: "text-green-500", bg: "bg-green-50" },
{ id: "b2b_portal", label: "B2B Portal", icon: Building2, color: "text-cyan-500", bg: "bg-cyan-50" },
{ id: "mobile_app", label: "Mobile App", icon: Smartphone, color: "text-purple-500", bg: "bg-purple-50" },
{ id: "website", label: "Website", icon: Globe, color: "text-indigo-500", bg: "bg-indigo-50" },
];
const channelSchema = Yup.object({
name: Yup.string().required("Channel name is required"),
code: Yup.string().required("Channel code is required"),
description: Yup.string(),
status: Yup.string().required(),
channelType: Yup.string().required("Please select a channel type"),
allowPublishing: Yup.boolean(),
});
export default function NewChannel() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useChannel();
const [selectedType, setSelectedType] = useState<string | null>(null);
const [form, setForm] = useState<Partial<ChannelCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isEdit && id) {
channelsService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
});
}
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateChannel(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as ChannelCreateRequest);
const formik = useFormik({
initialValues: {
name: "",
code: "",
description: "",
status: "active",
channelType: "",
allowPublishing: true,
},
validationSchema: channelSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as unknown as ChannelCreateRequest);
}
navigate("..");
} catch {
// handled
} finally {
setSubmitting(false);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
},
});
const inputClass = (error?: boolean) =>
`w-full border ${error ? "border-red-400 focus:ring-red-400" : "border-gray-200 focus:ring-purple-500"} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white`;
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit) {
const code = e.target.value.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "");
formik.setFieldValue("code", code);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Channel</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Top Action Bar */}
<div className="bg-white border-b border-gray-200 px-6 py-3 flex justify-end items-center gap-3 sticky top-0 z-10">
<Button variant="outline" type="button" onClick={() => navigate("..")} className="text-gray-600">
Cancel
</Button>
<Button
type="button"
onClick={() => formik.submitForm()}
loading={formik.isSubmitting}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Zap className="w-4 h-4 mr-2" />
{isEdit ? "Update Channel" : "Create Channel"}
</Button>
</div>
</PageWrapper>
<form onSubmit={formik.handleSubmit} className="flex-1 max-w-2xl mx-auto w-full py-8 px-4 space-y-6">
{/* 1. Basic Information */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">1</div>
<h2 className="font-semibold text-gray-900">Basic Information</h2>
</div>
<div className="p-6 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Channel Name *</label>
<input
name="name"
value={formik.values.name}
onChange={handleNameChange}
onBlur={formik.handleBlur}
placeholder="e.g., Shopify Storefront"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && (
<p className="text-xs text-red-500 mt-1">{formik.errors.name}</p>
)}
</div>
<div>
<label className={labelClass}>Channel Code *</label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., shopify_main"
className={inputClass(formik.touched.code && Boolean(formik.errors.code))}
/>
<p className="text-[11px] text-gray-400 mt-1">Unique identifier auto-generated from name</p>
</div>
</div>
<div>
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Brief description of this channel's purpose..."
rows={3}
className={inputClass() + " resize-none"}
/>
</div>
<div>
<label className={labelClass}>Status</label>
<div className="relative">
<select
name="status"
value={formik.values.status}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
</div>
</div>
{/* 2. Channel Type */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">2</div>
<h2 className="font-semibold text-gray-900">Channel Type</h2>
</div>
<div className="p-6">
<p className="text-sm text-gray-500 mb-4">Select the type of destination this channel represents.</p>
<div className="grid grid-cols-4 gap-3">
{CHANNEL_TYPES.map((type) => {
const Icon = type.icon;
const isSelected = selectedType === type.id;
return (
<button
key={type.id}
type="button"
onClick={() => {
setSelectedType(type.id);
formik.setFieldValue("channelType", type.id);
}}
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all gap-2 ${
isSelected
? "border-purple-500 bg-purple-50 shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${type.bg}`}>
<Icon className={`w-5 h-5 ${type.color}`} />
</div>
<span className={`text-xs font-medium ${isSelected ? "text-purple-700" : "text-gray-700"}`}>
{type.label}
</span>
</button>
);
})}
</div>
{formik.touched.channelType && formik.errors.channelType && (
<p className="text-xs text-red-500 mt-3">{formik.errors.channelType}</p>
)}
</div>
</div>
{/* 3. Channel Icon */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div className="w-6 h-6 bg-gray-400 text-white rounded-full flex items-center justify-center text-xs font-bold">3</div>
<h2 className="font-semibold text-gray-900">Channel Icon <span className="text-xs text-gray-400 font-normal ml-1">Optional</span></h2>
</div>
<div className="p-6">
<div className="flex items-start gap-8">
<div>
<p className="text-xs text-gray-500 font-medium mb-2">Upload Icon</p>
<div className="w-28 h-28 border-2 border-dashed border-gray-200 rounded-xl flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-purple-400 hover:bg-purple-50/30 transition-colors">
<Upload className="w-5 h-5 text-gray-400" />
<span className="text-[10px] text-gray-400 text-center leading-tight">Click to upload<br/>PNG, SVG, WebP</span>
</div>
</div>
<div>
<p className="text-xs text-gray-500 font-medium mb-2">Preview</p>
<div className="w-40 h-28 border border-gray-200 rounded-xl flex flex-col items-center justify-center gap-1 bg-gray-50">
<div className="w-8 h-8 rounded bg-gray-200 mb-1" />
<span className="text-xs font-medium text-gray-700">{formik.values.name || "Channel Name"}</span>
<span className="text-[10px] text-gray-400">{selectedType ? CHANNEL_TYPES.find(t => t.id === selectedType)?.label : "Type"}</span>
</div>
</div>
</div>
</div>
</div>
{/* 4. Availability */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">4</div>
<h2 className="font-semibold text-gray-900">Availability</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-50 rounded-lg flex items-center justify-center">
<Zap className="w-4 h-4 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">Allow Product Publishing</p>
<p className="text-xs text-gray-500">When enabled, products can be published and syndicated to this channel</p>
</div>
</div>
<button
type="button"
onClick={() => formik.setFieldValue("allowPublishing", !formik.values.allowPublishing)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
formik.values.allowPublishing ? "bg-purple-600" : "bg-gray-200"
}`}
>
<span className={`inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
formik.values.allowPublishing ? "translate-x-5" : "translate-x-0"
}`} />
</button>
</div>
</div>
</div>
{/* 5. Summary */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">5</div>
<h2 className="font-semibold text-gray-900">Summary</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
<div>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-2">Channel Information</p>
<div className="space-y-1.5">
<div className="flex justify-between">
<span className="text-gray-500">Name</span>
<span className="font-medium text-gray-900">{formik.values.name || "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Code</span>
<code className="font-mono text-xs text-gray-700 bg-gray-100 px-1.5 py-0.5 rounded">{formik.values.code || "—"}</code>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className="capitalize font-medium text-gray-900">{formik.values.status}</span>
</div>
</div>
</div>
<div>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-2">Type</p>
<div className="space-y-1.5">
<div className="flex justify-between">
<span className="text-gray-500">Channel Type</span>
<span className="font-medium text-gray-900">
{selectedType ? CHANNEL_TYPES.find(t => t.id === selectedType)?.label : "—"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Publishing</span>
<span className={`font-medium ${formik.values.allowPublishing ? "text-emerald-600" : "text-gray-400"}`}>
{formik.values.allowPublishing ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Actions */}
<div className="flex justify-end gap-3 py-4">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button type="submit" loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white">
<Zap className="w-4 h-4 mr-2" />
{isEdit ? "Update Channel" : "Create Channel"}
</Button>
</div>
</form>
</div>
);
}
@@ -1,6 +1,5 @@
import type { ChannelCreateRequest } from '../types/channels.types';
export const validateChannel = (data: Partial<ChannelCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Channel name is required';
return errors;
};
import * as Yup from 'yup';
export const channelSchema = Yup.object().shape({
name: Yup.string().required('Channel name is required'),
});
+53 -1
View File
@@ -20,6 +20,7 @@ import {
import { InfoCard, InfoCardGrid } from "../components/StatsCard";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
// ── Publication Trends data ───────────────────────────────────────────────────
const trendData = [
{ month: "Jan", products: 1200 },
@@ -49,6 +50,16 @@ const channelConfig: ChartConfig = {
"Mobile App": { label: "Mobile App", color: "#06b6d4" },
};
const recentActivity = [
{ id: '1', activity: 'Product created', detail: 'Premium Wireless Headphones', user: 'Sarah Johnson', status: 'success', time: '2 min ago' },
{ id: '2', activity: 'Approval requested', detail: 'Smart Home Bundle', user: 'Mike Chen', status: 'warning', time: '15 min ago' },
{ id: '3', activity: 'Product published', detail: 'Eco-Friendly Water Bottle', user: 'Emma Wilson', status: 'success', time: '1 hour ago' },
{ id: '4', activity: 'Import completed', detail: 'Supplier ABC - 150 products', user: 'System', status: 'info', time: '2 hours ago' },
{ id: '5', activity: 'Asset uploaded', detail: 'Product images batch #234', user: 'David Lee', status: 'success', time: '3 hours ago' },
] as const;
// ── Dashboard Component ───────────────────────────────────────────────────────
export default function Dashboard() {
return (
@@ -179,6 +190,47 @@ export default function Dashboard() {
</div>
</div>
</div>
</PageWrapper>
{/* Recent Activity */}
<div className="mt-6 bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-6">
<h3 className="text-sm font-semibold text-gray-900">Recent Activity</h3>
</div>
<div className="flex flex-col gap-6">
{recentActivity.map((item, idx) => (
<div key={item.id} className={`flex items-start justify-between ${idx !== recentActivity.length - 1 ? 'border-b border-gray-100 pb-6' : ''}`}>
<div className="flex gap-3">
<div className="mt-1">
<div className={`w-2 h-2 rounded-full ${
item.status === 'success' ? 'bg-emerald-500' :
item.status === 'warning' ? 'bg-amber-500' :
'bg-sky-500'
}`} />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900 text-sm">{item.activity}</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium border ${
item.status === 'success' ? 'bg-emerald-50 text-emerald-700 border-emerald-100' :
item.status === 'warning' ? 'bg-amber-50 text-amber-700 border-amber-100' :
'bg-sky-50 text-sky-700 border-sky-100'
}`}>
{item.status}
</span>
</div>
<div className="text-sm text-gray-500">
{item.detail} <span className="font-medium text-gray-700">by {item.user}</span>
</div>
</div>
</div>
<span className="text-xs text-gray-500 font-medium whitespace-nowrap">{item.time}</span>
</div>
))}
</div>
<div className="mt-6 text-center border-t border-gray-100 pt-6">
<button className="text-sm font-medium text-purple-600 hover:text-purple-700 transition-colors">
View all activity
</button>
</div>
</div> </PageWrapper>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { Table } from "../../../components/customs/Table";
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
import { useFamily } from "../hook/useFamily";
import type { Family } from "../types/family.types";
import { formatDate } from "../../../utils/formatters";
export default function FamilyList() {
const navigate = useNavigate();
+285 -267
View File
@@ -1,291 +1,309 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FolderTree } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useFamily } from "../hook/useFamily";
import { useAttribute } from "../../attributes/hook/useAttribute";
import { validateFamily } from "../validation/family.schema";
import type { FamilyCreateRequest } from "../types/family.types";
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, FileText, LayoutGrid, Tags, Globe, Eye, Settings2,
CheckCircle2, AlertCircle, Edit2, Trash2, Plus,
CheckSquare, Image as ImageIcon
} from 'lucide-react';
const TABS = [
{ id: 'basic', label: 'Basic Information', icon: FileText },
{ id: 'attributes', label: 'Attribute Groups', icon: LayoutGrid },
{ id: 'variants', label: 'Variant Strategy', icon: Tags },
{ id: 'channels', label: 'Allowed Channels', icon: Globe },
{ id: 'assets', label: 'Default Asset Types', icon: ImageIcon },
{ id: 'workflow', label: 'Workflow Assignment', icon: Settings2 },
{ id: 'rules', label: 'Completeness Rules', icon: CheckSquare },
{ id: 'summary', label: 'Inheritance Summary', icon: Eye }
];
export default function NewFamily() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const [activeTab, setActiveTab] = useState('basic');
const { createFamily, updateFamily, fetchFamilies, families } = useFamily();
const { fetchAttributes, attributes } = useAttribute();
const [form, setForm] = useState<Partial<FamilyCreateRequest>>({
code: "",
name: "",
description: "",
attributes: [],
variantAxes: [],
status: "active",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchFamilies();
fetchAttributes();
}, [fetchFamilies, fetchAttributes]);
useEffect(() => {
if (isEdit && families.length > 0) {
const match = families.find((item) => item.id === id);
if (match) {
setForm({
code: match.code,
name: match.name,
description: match.description || "",
attributes: match.attributes || [],
variantAxes: match.variantAxes || [],
status: match.status,
});
}
}
}, [isEdit, id, families]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, status: e.target.value as any }));
};
const handleAttributeToggle = (code: string) => {
setForm((prev) => {
const current = prev.attributes || [];
const updated = current.includes(code)
? current.filter((c) => c !== code)
: [...current, code];
// If we uncheck an attribute, make sure it is also removed from variant axes
const updatedAxes = (prev.variantAxes || []).filter((axis) => updated.includes(axis));
return {
...prev,
attributes: updated,
variantAxes: updatedAxes,
};
});
};
const handleAxisToggle = (code: string) => {
setForm((prev) => {
const current = prev.variantAxes || [];
const updated = current.includes(code)
? current.filter((c) => c !== code)
: [...current, code];
return {
...prev,
variantAxes: updated,
};
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateFamily(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateFamily(id, form);
} else {
await createFamily(form as FamilyCreateRequest);
}
navigate("/families");
} catch {
// hook handles toast
} finally {
setSaving(false);
}
};
const activeAttributes = attributes.filter((a) => a.status === "active");
const inputClass = "w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
return (
<ProtectedRoute node="products.families">
<PageWrapper>
{/* Back and Header */}
<div className="mb-6">
<button
onClick={() => navigate("/families")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4 sticky top-0 z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/families')}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
Back to Product Families
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
<FolderTree className="w-5 h-5" />
</div>
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? "Edit Product Family" : "Create Product Family"}
</h1>
<div>
<h1 className="text-xl font-bold text-gray-900">Create Product Family</h1>
<p className="text-sm text-gray-500 mt-0.5">Define a reusable product template</p>
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={() => navigate('/families')} className="px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2">
Cancel
</button>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
<Save className="w-4 h-4" /> Create Family
</button>
</div>
</div>
{/* Form Container */}
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-3xl shadow-sm">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Code */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Family Code <span className="text-red-500">*</span>
</label>
<input
type="text"
name="code"
value={form.code}
onChange={handleChange}
disabled={isEdit}
placeholder="e.g. apparel"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
} disabled:bg-gray-50 disabled:text-gray-400`}
/>
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
</div>
{/* Name */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Family Name <span className="text-red-500">*</span>
</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
placeholder="e.g. Apparel"
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
<div className="flex-1 max-w-[1400px] w-full mx-auto px-6 py-6">
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200 mb-6 overflow-x-auto hide-scrollbar">
{TABS.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors shrink-0 ${
isActive ? 'border-purple-600 text-purple-700 bg-purple-50/50' : 'border-transparent text-gray-500 hover:text-gray-900 hover:bg-gray-50'
}`}
/>
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
</div>
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
{/* Description */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Description</label>
<textarea
name="description"
value={form.description}
onChange={handleChange}
placeholder="e.g. Products requiring variant sizes and colors"
rows={3}
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all"
/>
</div>
{/* Attributes Checklist */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Linked Attributes</label>
{activeAttributes.length === 0 ? (
<p className="text-sm text-gray-450 italic">No attributes defined. Create some attributes first.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 border border-gray-150 rounded-lg p-4 bg-gray-50/50 max-h-48 overflow-y-auto">
{activeAttributes.map((attr) => {
const isChecked = (form.attributes || []).includes(attr.code);
return (
<label key={attr.id} className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleAttributeToggle(attr.code)}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="capitalize">{attr.name}</span>
</label>
);
})}
<div className="max-w-4xl mx-auto">
{activeTab === 'basic' && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-6">Family Details</h3>
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<label className={labelClass}>Family Name <span className="text-red-500">*</span></label>
<input placeholder="e.g., Laptop Family" className={inputClass} />
</div>
)}
</div>
{/* Variant Axes Checklist */}
{(form.attributes || []).length > 0 && (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Variant Axes (Select from linked attributes)
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 border border-gray-150 rounded-lg p-4 bg-gray-50/50 max-h-48 overflow-y-auto">
{(form.attributes || []).map((code) => {
const attr = activeAttributes.find((a) => a.code === code);
if (!attr) return null;
const isChecked = (form.variantAxes || []).includes(code);
return (
<label key={code} className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleAxisToggle(code)}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="capitalize">{attr.name}</span>
</label>
);
})}
<div>
<label className={labelClass}>Family Code <span className="text-red-500">*</span></label>
<input placeholder="e.g., laptop_family" className={`${inputClass} bg-gray-50`} />
</div>
<p className="text-xs text-gray-400 mt-1">
Selected attributes will serve as the coordinate axes for item variant variations.
<div className="col-span-2">
<label className={labelClass}>Description</label>
<textarea placeholder="Describe this product family..." rows={3} className={`${inputClass} resize-none`} />
</div>
<div>
<label className={labelClass}>Status</label>
<select className={inputClass} defaultValue="Draft">
<option value="Draft">Draft</option>
<option value="Active">Active</option>
</select>
</div>
<div>
<label className={labelClass}>Category Assignment</label>
<select className={inputClass}>
<option>Select a category...</option>
</select>
</div>
</div>
</div>
)}
{activeTab === 'attributes' && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-4">Attribute Group Assignment</h3>
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 mb-6">
<p className="text-sm text-blue-800">
Select which attribute groups products in this family should inherit. All products will also inherit platform and category-level groups automatically.
</p>
</div>
)}
{/* Status */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={form.status === "active"}
onChange={handleStatusChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700">Active</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={form.status === "inactive"}
onChange={handleStatusChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700">Inactive</span>
</label>
<div className="space-y-3">
{[
{ name: 'Technical Specifications', desc: '16 attributes', checked: true },
{ name: 'Marketing Content', desc: '10 attributes', checked: true },
{ name: 'Physical Attributes', desc: '8 attributes', checked: false },
{ name: 'Compliance', desc: '6 attributes', checked: false },
{ name: 'Warranty', desc: '4 attributes', checked: false },
].map((group, i) => (
<label key={i} className={`flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-colors ${
group.checked ? 'border-purple-200 bg-purple-50/30' : 'border-gray-200 hover:border-gray-300'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={group.checked} readOnly className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" />
<div>
<div className="font-medium text-sm text-gray-900">{group.name}</div>
<div className="text-xs text-gray-500">{group.desc}</div>
</div>
</div>
{group.checked && <CheckCircle2 className="w-5 h-5 text-purple-500" />}
</label>
))}
</div>
</div>
)}
{/* Buttons */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
<Button variant="outline" type="button" onClick={() => navigate("/families")} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={saving}>
{isEdit ? "Save Changes" : "Create Family"}
</Button>
{activeTab === 'variants' && (
<div className="space-y-6">
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<h4 className="font-medium text-amber-900 text-sm">Variant axes are inherited from the Product Family. Variant values are defined for each product individually.</h4>
<p className="text-xs text-amber-700 mt-1">
This family defines ONLY the variant dimensions (axes). When creating products from this family, users will add their own values for each axis (e.g., "Black, Blue, White" for a Color axis).
</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-semibold text-gray-900 mb-1">Variant Axes</h3>
<p className="text-sm text-gray-500">Define the variant dimensions for this family (e.g., Color, Storage, Size)</p>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
<Plus className="w-4 h-4" /> Add Variant Axis
</button>
</div>
<div className="border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs font-semibold text-gray-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Axis Name</th>
<th className="px-6 py-4">Code</th>
<th className="px-6 py-4">Description</th>
<th className="px-6 py-4">Data Type</th>
<th className="px-6 py-4">Required</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{[
{ name: 'Color', code: 'color', desc: 'Product color variations', type: 'color swatch', req: 'Yes', status: 'Active' },
{ name: 'Storage', code: 'storage', desc: 'Storage capacity variations', type: 'dropdown', req: 'Yes', status: 'Active' },
{ name: 'RAM', code: 'ram', desc: 'Memory capacity variations', type: 'dropdown', req: 'No', status: 'Active' },
].map((axis, i) => (
<tr key={i} className="bg-white hover:bg-gray-50">
<td className="px-6 py-4 font-medium text-gray-900">{axis.name}</td>
<td className="px-6 py-4 text-gray-500">{axis.code}</td>
<td className="px-6 py-4 text-gray-500">{axis.desc}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs font-medium rounded-md ${
axis.type === 'color swatch' ? 'bg-purple-50 text-purple-700 border border-purple-200' : 'bg-amber-50 text-amber-700 border border-amber-200'
}`}>{axis.type}</span>
</td>
<td className="px-6 py-4">
<span className={axis.req === 'Yes' ? 'text-emerald-600 font-medium' : 'text-gray-500'}>{axis.req}</span>
</td>
<td className="px-6 py-4">
<span className="text-emerald-600 text-xs font-medium">{axis.status}</span>
</td>
<td className="px-6 py-4 flex items-center justify-end gap-3 text-gray-400">
<button className="hover:text-gray-900"><Edit2 className="w-4 h-4" /></button>
<button className="hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</form>
)}
{activeTab === 'channels' && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-6">Allowed Channels</h3>
<div className="grid grid-cols-2 gap-4">
{[
{ name: 'Amazon', desc: 'Amazon Marketplace', checked: true },
{ name: 'Shopify', desc: 'E-commerce storefront', checked: true },
{ name: 'POS', desc: 'Point of Sale systems', checked: true },
{ name: 'B2B Portal', desc: 'Business customers', checked: false },
{ name: 'Mobile App', desc: 'Mobile application', checked: false },
].map((channel, i) => (
<label key={i} className={`flex items-start justify-between p-4 rounded-xl border cursor-pointer transition-colors ${
channel.checked ? 'border-emerald-200 bg-emerald-50/30' : 'border-gray-200 hover:border-gray-300'
}`}>
<div>
<div className="font-semibold text-sm text-gray-900 mb-0.5">{channel.name}</div>
<div className="text-xs text-gray-500">{channel.desc}</div>
</div>
<input type="checkbox" checked={channel.checked} readOnly className="w-4 h-4 text-emerald-600 rounded border-gray-300 focus:ring-emerald-500 mt-0.5" />
</label>
))}
</div>
</div>
)}
{activeTab === 'assets' && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-6">Default Asset Types</h3>
<div className="space-y-3">
{[
{ name: 'Primary Image', req: true, checked: true },
{ name: 'Gallery Images', req: false, checked: true },
{ name: 'Product Video', req: false, checked: false },
{ name: 'Technical Documentation', req: false, checked: false },
].map((asset, i) => (
<label key={i} className={`flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-colors ${
asset.checked ? 'border-blue-200 bg-blue-50/30' : 'border-gray-200 hover:border-gray-300'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={asset.checked} readOnly className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500" />
<div>
<div className="font-medium text-sm text-gray-900">{asset.name}</div>
{asset.req && <div className="text-xs text-red-500 mt-0.5">Required</div>}
</div>
</div>
{asset.checked && <CheckCircle2 className="w-5 h-5 text-blue-500" />}
</label>
))}
</div>
</div>
)}
{activeTab === 'rules' && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<h3 className="font-semibold text-gray-900 mb-6">Completeness Rules</h3>
<div className="space-y-3">
{[
{ name: 'All required attributes filled', weight: '40%', checked: true },
{ name: 'At least one product image', weight: '20%', checked: true },
{ name: 'Marketing content complete', weight: '15%', checked: true },
{ name: 'Technical specifications complete', weight: '15%', checked: false },
{ name: 'All variants have SKUs', weight: '10%', checked: true },
].map((rule, i) => (
<label key={i} className={`flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-colors border-gray-200 hover:border-gray-300`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={rule.checked} readOnly className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" />
<div className="font-medium text-sm text-gray-900">{rule.name}</div>
</div>
<div className="text-sm font-medium text-gray-700">{rule.weight}</div>
</label>
))}
</div>
</div>
)}
{!['basic', 'attributes', 'variants', 'channels', 'assets', 'rules'].includes(activeTab) && (
<div className="bg-white border border-gray-200 border-dashed rounded-xl p-12 flex flex-col items-center justify-center text-center">
<Globe className="w-8 h-8 text-gray-300 mb-3" />
<h3 className="text-gray-900 font-semibold mb-1">Coming Soon</h3>
<p className="text-sm text-gray-500">This section is currently under development.</p>
</div>
)}
</div>
</PageWrapper>
</ProtectedRoute>
</div>
</div>
);
}
}
// Ensure Save icon is imported
const Save = ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
);
+11 -16
View File
@@ -1,17 +1,12 @@
import type { FamilyCreateRequest } from '../types/family.types';
import * as Yup from 'yup';
export const validateFamily = (data: Partial<FamilyCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.code?.trim()) {
errors.code = 'Family code is required';
} else if (!/^[a-z0-9_]+$/.test(data.code)) {
errors.code = 'Code can only contain lowercase letters, numbers, and underscores';
}
if (!data.name?.trim()) {
errors.name = 'Family name is required';
}
return errors;
};
export const familySchema = Yup.object().shape({
code: Yup.string()
.required('Family code is required')
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores'),
name: Yup.string().required('Family name is required'),
description: Yup.string(),
attributes: Yup.array().of(Yup.string()),
variantAxes: Yup.array().of(Yup.string()),
status: Yup.string().oneOf(['active', 'inactive']),
});
@@ -0,0 +1,201 @@
import type { ImportRecord, ImportStatus } from "../types/imports.types";
interface ImportCardProps {
record: ImportRecord;
}
const STATUS_CONFIG: Record<ImportStatus, {
label: string;
dot: string;
bg: string;
text: string;
border: string;
progress: string;
}> = {
processing: {
label: "processing",
dot: "bg-amber-400",
bg: "bg-amber-50",
text: "text-amber-700",
border: "border-amber-200",
progress: "bg-amber-500",
},
completed: {
label: "completed",
dot: "bg-green-500",
bg: "bg-green-50",
text: "text-green-700",
border: "border-green-200",
progress: "bg-green-500",
},
failed: {
label: "failed",
dot: "bg-red-500",
bg: "bg-red-50",
text: "text-red-700",
border: "border-red-200",
progress: "bg-red-500",
},
pending: {
label: "pending",
dot: "bg-gray-400",
bg: "bg-gray-50",
text: "text-gray-500",
border: "border-gray-200",
progress: "bg-gray-400",
},
};
const FILE_TYPE_ICON = () => (
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
const PauseIcon = () => (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
);
const DownloadIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
);
const RetryIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
);
function formatDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", { year: "numeric", month: "2-digit", day: "2-digit" })
+ " " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
}
export function ImportCard({ record }: ImportCardProps) {
const cfg = STATUS_CONFIG[record.status];
const pct = record.totalRecords > 0
? Math.round((record.processedRecords / record.totalRecords) * 100)
: 0;
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
{/* Top Row: supplier info + actions */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
{/* Status icon circle */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${
record.status === "completed" ? "border-green-400 bg-green-50"
: record.status === "processing" ? "border-amber-400 bg-amber-50"
: record.status === "failed" ? "border-red-400 bg-red-50"
: "border-gray-300 bg-gray-50"
}`}>
{record.status === "completed" && (
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
)}
{record.status === "processing" && (
<svg className="w-5 h-5 text-amber-500 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" strokeDasharray="30 60" strokeLinecap="round"/>
</svg>
)}
{record.status === "failed" && (
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9l-6 6M9 9l6 6"/>
</svg>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900 text-base">{record.supplierName}</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${cfg.bg} ${cfg.text} border ${cfg.border}`}>
{cfg.label}
</span>
<span className="text-sm text-gray-500 font-mono">{record.importCode}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-gray-500">
<FILE_TYPE_ICON />
<span>{record.fileName}</span>
<span className="text-gray-300"></span>
<span>{formatDate(record.importedAt)}</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3 text-sm text-gray-500">
{record.status === "processing" && (
<button className="flex items-center gap-1.5 hover:text-gray-700 transition-colors">
<PauseIcon /> Pause
</button>
)}
{record.status === "failed" && (
<button className="flex items-center gap-1.5 hover:text-gray-700 transition-colors">
<RetryIcon /> Retry
</button>
)}
<button className="flex items-center gap-1.5 hover:text-gray-700 transition-colors">
<DownloadIcon /> Download
</button>
</div>
</div>
{/* Progress */}
<div className="mt-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-gray-500">Progress</span>
<span className="text-xs font-medium text-gray-700">
{record.processedRecords.toLocaleString()} / {record.totalRecords.toLocaleString()} records ({pct}%)
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-500 ${cfg.progress}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Stats: Processed / Success / Errors */}
<div className="grid grid-cols-3 gap-3 mt-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Processed</div>
<div className="text-xl font-bold text-gray-900">{record.processedRecords.toLocaleString()}</div>
</div>
<div className="bg-green-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Success</div>
<div className="text-xl font-bold text-green-600">{record.successRecords.toLocaleString()}</div>
</div>
<div className="bg-red-50 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Errors</div>
<div className="text-xl font-bold text-red-500">{record.errorRecords.toLocaleString()}</div>
</div>
</div>
{/* Error alert */}
{record.errorRecords > 0 && (
<div className="mt-3 flex items-center justify-between bg-red-50 border border-red-200 rounded-lg px-4 py-3">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<div>
<span className="text-sm font-semibold text-red-700">{record.errorRecords} errors detected</span>
<p className="text-xs text-red-500">Review the error log to resolve issues with failed records</p>
</div>
</div>
<button className="px-3 py-1.5 bg-red-600 text-white text-xs font-semibold rounded-lg hover:bg-red-700 transition-colors">
View Errors
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,26 @@
import { supplierPerformance } from "../data/mockImports";
export function SupplierPerformancePanel() {
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm h-full">
<h3 className="font-semibold text-gray-900 mb-4">Supplier Performance</h3>
<div className="space-y-5">
{supplierPerformance.map((s) => (
<div key={s.name}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-800">{s.name}</span>
<span className="text-sm font-semibold text-gray-900">{s.rate}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2 mb-1">
<div
className="h-2 rounded-full bg-violet-600 transition-all duration-700"
style={{ width: `${s.rate}%` }}
/>
</div>
<span className="text-xs text-gray-400">{s.imports} imports</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { useRef, useState } from "react";
const UploadIcon = () => (
<svg className="w-10 h-10 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
);
export function UploadNewImportPanel() {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFile = (file: File) => setSelectedFile(file);
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm h-full">
<h3 className="font-semibold text-gray-900 mb-4">Upload New Import</h3>
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
className={`border-2 border-dashed rounded-xl flex flex-col items-center justify-center py-10 px-6 transition-colors cursor-pointer ${
dragging ? "border-violet-400 bg-violet-50" : "border-gray-200 bg-gray-50 hover:border-violet-300 hover:bg-violet-50/30"
}`}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept=".csv,.xlsx,.xml"
className="hidden"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
<div className="w-16 h-16 bg-violet-100 rounded-full flex items-center justify-center mb-3">
<UploadIcon />
</div>
{selectedFile ? (
<>
<p className="text-sm font-semibold text-gray-800">{selectedFile.name}</p>
<p className="text-xs text-gray-500 mt-0.5">{(selectedFile.size / 1024).toFixed(1)} KB</p>
</>
) : (
<>
<p className="text-sm font-medium text-gray-700">Upload supplier data file</p>
<p className="text-xs text-gray-400 mt-0.5">Supports CSV, XLSX, and XML formats</p>
</>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
className="mt-4 px-5 py-2 bg-violet-600 text-white text-sm font-semibold rounded-lg hover:bg-violet-700 transition-colors"
>
{selectedFile ? "Change File" : "Select File"}
</button>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { ImportRecord } from "../types/imports.types";
// ── Mock data matching the screenshots ────────────────────────────────────────
export const mockImports: ImportRecord[] = [
{
id: "IMP-001",
supplierName: "Supplier ABC",
importCode: "IMP-001",
fileName: "products_batch_234.csv",
fileType: "csv",
importedAt: "2024-05-14T10:30:00",
status: "processing",
totalRecords: 1500,
processedRecords: 1005,
successRecords: 982,
errorRecords: 23,
},
{
id: "IMP-002",
supplierName: "Supplier XYZ",
importCode: "IMP-002",
fileName: "inventory_update.xlsx",
fileType: "xlsx",
importedAt: "2024-05-14T09:15:00",
status: "completed",
totalRecords: 850,
processedRecords: 850,
successRecords: 850,
errorRecords: 0,
},
{
id: "IMP-003",
supplierName: "Supplier DEF",
importCode: "IMP-003",
fileName: "new_products.xml",
fileType: "xml",
importedAt: "2024-05-14T08:45:00",
status: "failed",
totalRecords: 2200,
processedRecords: 990,
successRecords: 856,
errorRecords: 134,
},
];
export const supplierPerformance = [
{ name: "Supplier ABC", rate: 98.5, imports: 8 },
{ name: "Supplier XYZ", rate: 100, imports: 12 },
{ name: "Supplier DEF", rate: 87.2, imports: 4 },
];
+106 -43
View File
@@ -1,55 +1,118 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useImport } from "../hook/useImport";
import type { Import } from "../types/imports.types";
import { ImportCard } from "../components/ImportCard";
import { SupplierPerformancePanel } from "../components/SupplierPerformancePanel";
import { UploadNewImportPanel } from "../components/UploadNewImportPanel";
import { mockImports } from "../data/mockImports";
export default function ImportList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useImport();
const [selected, setSelected] = useState<Set<string>>(new Set());
// ── Stat Icons ─────────────────────────────────────────────────────────────────
const UploadStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
);
const CheckStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
);
const ClockStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path strokeLinecap="round" d="M12 6v6l4 2"/>
</svg>
);
const XStat = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9l-6 6M9 9l6 6"/>
</svg>
);
const NewImportIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Import) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
];
const actions = (item: Import) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
// ── KPI Card ───────────────────────────────────────────────────────────────────
interface StatCardProps {
label: string;
value: number;
icon: React.ReactNode;
iconBg: string;
iconColor: string;
}
function StatCard({ label, value, icon, iconBg, iconColor }: StatCardProps) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 flex items-center gap-4 shadow-sm">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${iconBg}`}>
<span className={iconColor}>{icon}</span>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">{value}</div>
<div className="text-sm text-gray-500">{label}</div>
</div>
</div>
);
}
// ── Main Page ──────────────────────────────────────────────────────────────────
export default function ImportList() {
const stats = {
total: 24,
completed: 18,
processing: 3,
failed: 3,
};
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Imports</h1>
<p className="text-sm text-gray-500">Manage your imports</p>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Import
</Button>
{/* Breadcrumb */}
<div className="text-xs text-gray-400 mb-3 flex items-center gap-1.5">
<span className="hover:text-gray-600 cursor-pointer transition-colors">Dashboard</span>
<svg className="w-3.5 h-3.5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
</svg>
<span className="text-gray-600 font-medium">Supplier Imports</span>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Supplier Import Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">Import and sync product data from suppliers</p>
</div>
<button className="inline-flex items-center gap-2 px-4 py-2.5 bg-violet-600 hover:bg-violet-700 text-white text-sm font-semibold rounded-xl transition-colors shadow-sm self-start sm:self-auto">
<NewImportIcon />
New Import
</button>
</div>
{/* KPI Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard label="Total Imports" value={stats.total} icon={<UploadStat />} iconBg="bg-violet-100" iconColor="text-violet-600" />
<StatCard label="Completed" value={stats.completed} icon={<CheckStat />} iconBg="bg-green-100" iconColor="text-green-600" />
<StatCard label="Processing" value={stats.processing} icon={<ClockStat />} iconBg="bg-amber-100" iconColor="text-amber-500" />
<StatCard label="Failed" value={stats.failed} icon={<XStat />} iconBg="bg-red-100" iconColor="text-red-500" />
</div>
{/* Recent Imports Section */}
<div className="mb-6">
<div className="bg-white border border-gray-200 rounded-xl shadow-sm">
<div className="px-5 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-900">Recent Imports</h2>
</div>
<div className="p-5 space-y-4">
{mockImports.map((record) => (
<ImportCard key={record.id} record={record} />
))}
</div>
</div>
</div>
{/* Bottom: Upload + Supplier Performance */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<UploadNewImportPanel />
<SupplierPerformancePanel />
</div>
</PageWrapper>
);
+102 -42
View File
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useImport } from "../hook/useImport";
import { validateImport } from "../validation/imports.schema";
import { importSchema } from "../validation/imports.schema";
import { importsService } from "../services/imports.service";
import type { ImportCreateRequest } from "../types/imports.types";
import { ArrowLeft, Save } from 'lucide-react';
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewImport() {
const navigate = useNavigate();
@@ -13,57 +20,110 @@ export default function NewImport() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useImport();
const [form, setForm] = useState<Partial<ImportCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active",
},
validationSchema: importSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as ImportCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
importsService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateImport(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as ImportCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Import</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('..')}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Import' : 'Create Import'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update import details' : 'Configure a new data import job'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Import Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Daily Product Sync"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Import' : 'Create Import'}
</Button>
</div>
</form>
</PageWrapper>
);
}
@@ -1,3 +1,20 @@
export type ImportStatus = 'processing' | 'completed' | 'failed' | 'pending';
export interface ImportRecord {
id: string;
supplierName: string;
importCode: string;
fileName: string;
fileType: 'csv' | 'xlsx' | 'xml';
importedAt: string;
status: ImportStatus;
totalRecords: number;
processedRecords: number;
successRecords: number;
errorRecords: number;
}
// Legacy compatibility
export interface Import {
id: string;
name: string;
@@ -1,6 +1,5 @@
import type { ImportCreateRequest } from '../types/imports.types';
export const validateImport = (data: Partial<ImportCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Import name is required';
return errors;
};
import * as Yup from 'yup';
export const importSchema = Yup.object().shape({
name: Yup.string().required('Import name is required'),
});
@@ -1,55 +1,191 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Plus, Edit2, Eye, MoreHorizontal, Activity, FileText, Download, Plug, CheckCircle, RefreshCw, AlertCircle, Package, Clock, ShoppingCart, ShoppingBag, Smartphone } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useIntegration } from "../hook/useIntegration";
import type { Integration } from "../types/integrations.types";
import { KPI, KPIGrid } from "../../../components/customs/KPI";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { SearchBar } from "../../../components/customs/SearchBar";
// Mock Data
const MOCK_INTEGRATIONS = [
{ id: "1", name: "Amazon India", desc: "Primary Amazon India marketplace...", channel: "Amazon India", type: "Marketplace", env: "Production", status: "Connected", lastSync: "2025-06-09 14:32", syncErrors: 3, published: "4,240", author: "Sarah Chen", createdAt: "2024-01-10", icon: ShoppingBag, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
{ id: "2", name: "Amazon UAE", desc: "Amazon UAE marketplace for Middle...", channel: "Amazon UAE", type: "Marketplace", env: "Production", status: "Connected", lastSync: "2025-06-09 13:15", syncErrors: 0, published: "1,840", author: "Michael Torres", createdAt: "2024-02-14", icon: ShoppingBag, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
{ id: "3", name: "Shopify Main Store", desc: "Main Shopify storefront with...", channel: "Shopify Storefront", type: "E-Commerce", env: "Production", status: "Connected", lastSync: "2025-06-09 14:00", syncErrors: 12, published: "8,942", author: "Emma Wilson", createdAt: "2024-03-01", icon: ShoppingCart, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
{ id: "4", name: "Retail POS Network", desc: "Point-of-sale retail network product...", channel: "Retail POS", type: "POS", env: "Production", status: "Connected", lastSync: "2025-06-09 12:00", syncErrors: 0, published: "3,240", author: "David Park", createdAt: "2024-04-15", icon: Smartphone, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
];
export default function IntegrationList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useIntegration();
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Integration) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const [activeTab, setActiveTab] = useState("Connections");
const [searchQuery, setSearchQuery] = useState("");
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
{
key: "name",
label: "Integration Name",
sortable: true,
render: (_: any, row: any) => {
const Icon = row.icon;
return (
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center shrink-0 ${row.iconBg}`}>
<Icon className={`w-4 h-4 ${row.iconColor}`} />
</div>
<div>
<div className="font-medium text-gray-900">{row.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.desc}</div>
</div>
</div>
);
},
},
{ key: "channel", label: "Channel" },
{ key: "type", label: "Type" },
{
key: "env",
label: "Environment",
render: (val: string) => <span className="text-blue-600 text-sm">{val}</span>,
},
{
key: "status",
label: "Connection Status",
render: (val: string) => <StatusBadge status={val === "Connected" ? "success" : "neutral"} label={val} />,
},
{
key: "lastSync",
label: "Last Sync",
render: (_: any, row: any) => (
<div>
<div className="text-gray-900 font-medium text-sm">{row.lastSync}</div>
{row.syncErrors > 0 && (
<div className="text-red-600 text-xs mt-0.5">{row.syncErrors} failed</div>
)}
</div>
),
},
{ key: "published", label: "Published" },
{
key: "createdAt",
label: "Created By",
render: (_: any, row: any) => (
<div>
<div className="text-gray-900 text-sm">{row.author}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.createdAt}</div>
</div>
),
},
];
const actions = (item: Integration) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
const actions = () => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><MoreHorizontal className="w-4 h-4" /></button>
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Integrations</h1>
<p className="text-sm text-gray-500">Manage your integrations</p>
<div className="text-sm text-gray-500 mb-1">Integration / Integration Hub</div>
<h1 className="text-2xl font-bold text-gray-900">Integration Hub</h1>
<p className="text-sm text-gray-500">Manage external system connections, publishing rules, synchronisation jobs, and integration monitoring</p>
</div>
<div className="flex gap-3">
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
<Activity className="w-4 h-4 mr-2" />
Sync Dashboard
</Button>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
<FileText className="w-4 h-4 mr-2" />
View Logs
</Button>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
<Download className="w-4 h-4 mr-2" />
Export Config
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
<Plus className="w-4 h-4 mr-2" />
New Integration
</Button>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Integration
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* Stats Cards */}
<KPIGrid className="mb-6 grid-cols-1 sm:grid-cols-3 lg:grid-cols-6">
<KPI value="6" label="Total Integrations" icon={Plug} variant="purple" dots={[{ color: "amber", label: "4 active" }]} />
<KPI value="4" label="Connected Systems" icon={CheckCircle} variant="green" dots={[{ color: "green", label: "Healthy" }]} />
<KPI value="1" label="Active Sync Jobs" icon={RefreshCw} variant="blue" dots={[{ color: "blue", label: "In progress" }]} />
<KPI value="1" label="Failed Sync Jobs" icon={AlertCircle} variant="red" dots={[{ color: "red", label: "Need attention" }]} />
<KPI value="18,262" label="Published Products" icon={Package} variant="amber" dots={[{ color: "blue", label: "Across channels" }]} />
<KPI value="14:32" label="Last Synchronised" icon={Clock} variant="slate" dots={[{ color: "green", label: "Today" }]} />
</KPIGrid>
{/* Main Content Area */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col">
{/* Tabs */}
<div className="flex border-b border-gray-100 px-2 pt-2">
{[
{ id: 'Connections', count: 6 },
{ id: 'Publishing Rules', count: 5 },
{ id: 'Sync Jobs', count: 6 },
{ id: 'Error Center', count: 3 },
{ id: 'Audit & Logs', count: null }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
{tab.id}
{tab.count !== null && (
<span className={`px-2 py-0.5 rounded-full text-xs ${
activeTab === tab.id ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Toolbar */}
<div className="p-4 flex justify-between items-center border-b border-gray-100">
<div className="flex gap-4 items-center">
<div className="w-64">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search integrations..." />
</div>
<select className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white">
<option>All Status</option>
<option>Connected</option>
<option>Disconnected</option>
</select>
<select className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white">
<option>All Types</option>
<option>Marketplace</option>
<option>E-Commerce</option>
</select>
</div>
<div className="text-sm text-gray-500">
6 integrations
</div>
</div>
{/* Table */}
<div className="p-4">
<Table
columns={columns}
data={MOCK_INTEGRATIONS}
actions={actions}
/>
</div>
</div>
</PageWrapper>
);
@@ -1,69 +1,581 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
ArrowLeft, Zap, ShoppingCart, Server, Warehouse,
CreditCard, Code2, Store, Eye, EyeOff, Settings2,
Calendar, Clock, Wifi, ChevronDown, Info
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { useIntegration } from "../hook/useIntegration";
import { validateIntegration } from "../validation/integrations.schema";
import { integrationsService } from "../services/integrations.service";
import type { IntegrationCreateRequest } from "../types/integrations.types";
const TABS = ["General Information", "Connection Configuration", "Synchronisation Settings", "Advanced Configuration"];
const INTEGRATION_TYPES = [
{ id: "marketplace", label: "Marketplace", sub: "Amazon, eBay, Flipkart, Noon", icon: Store },
{ id: "ecommerce", label: "E-Commerce", sub: "Shopify, WooCommerce, Magento", icon: ShoppingCart },
{ id: "erp", label: "ERP", sub: "SAP, Oracle, Microsoft Dynamics", icon: Server },
{ id: "wms", label: "WMS", sub: "Warehouse management systems", icon: Warehouse },
{ id: "pos", label: "POS", sub: "Retail point-of-sale systems", icon: CreditCard },
{ id: "custom_api", label: "Custom API", sub: "Any REST or GraphQL endpoint", icon: Code2 },
];
const integrationSchema = Yup.object({
name: Yup.string().required("Integration name is required"),
channel: Yup.string().required("Channel is required"),
integrationType: Yup.string().required("Please select an integration type"),
environment: Yup.string().required(),
status: Yup.string().required(),
description: Yup.string(),
storeUrl: Yup.string().url("Must be a valid URL").when("integrationType", {
is: "ecommerce",
then: (s) => s.required("Store URL is required"),
otherwise: (s) => s,
}),
accessToken: Yup.string(),
apiVersion: Yup.string(),
webhookSecret: Yup.string(),
shopIdentifier: Yup.string(),
syncDirection: Yup.string().required(),
syncFrequency: Yup.string().required(),
autoRetry: Yup.boolean(),
retryAttempts: Yup.number().min(1).max(10),
});
export default function NewIntegration() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useIntegration();
const [activeTab, setActiveTab] = useState(0);
const [showToken, setShowToken] = useState(false);
const [form, setForm] = useState<Partial<IntegrationCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isEdit && id) {
integrationsService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
});
}
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateIntegration(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as IntegrationCreateRequest);
const formik = useFormik({
initialValues: {
name: "",
channel: "",
integrationType: "ecommerce",
environment: "production",
status: "pending",
description: "",
storeUrl: "",
accessToken: "",
apiVersion: "2025-01",
webhookSecret: "",
shopIdentifier: "",
syncDirection: "pim_to_channel",
syncFrequency: "hourly",
autoRetry: true,
retryAttempts: 3,
},
validationSchema: integrationSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
console.log("Integration created:", values);
navigate("..");
} catch {
// handled
} finally {
setSubmitting(false);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
},
});
const inputClass = (error?: boolean) =>
`w-full border ${error ? "border-red-400 focus:ring-red-400" : "border-gray-200 focus:ring-purple-500"} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white`;
const selectedType = INTEGRATION_TYPES.find((t) => t.id === formik.values.integrationType);
const IntegrationSummary = () => (
<div className="w-60 shrink-0">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 sticky top-24">
<div className="flex items-center gap-2 mb-5">
<Zap className="w-4 h-4 text-amber-500" />
<h3 className="font-semibold text-gray-900 text-sm">Integration Summary</h3>
</div>
<div className="space-y-3.5 text-sm">
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Name</span>
<span className="font-medium text-gray-900 text-right truncate">{formik.values.name || "—"}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Channel</span>
<span className="font-medium text-gray-900 text-right">{formik.values.channel || "—"}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Type</span>
<span className="font-medium text-gray-900">{selectedType?.label || "—"}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Environment</span>
<span className="font-medium text-gray-900 capitalize">{formik.values.environment}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Sync Direction</span>
<span className="font-medium text-gray-900">PIM </span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Sync Frequency</span>
<span className="font-medium text-gray-900 capitalize">{formik.values.syncFrequency}</span>
</div>
</div>
<div className="mt-5 pt-5 border-t border-gray-100">
<button
type="button"
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors"
>
<Zap className="w-4 h-4" />
Test Connection
</button>
</div>
</div>
</div>
);
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Integration</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Top Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4 sticky top-0 z-10">
<nav className="text-xs text-gray-500 mb-2 flex items-center gap-1.5">
<span className="cursor-pointer hover:text-gray-700" onClick={() => navigate("..")}>Integration Hub</span>
<span></span>
<span className="text-gray-900">Create Integration</span>
</nav>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate("..")}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-xl font-bold text-gray-900">New Integration</h1>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
<div className="flex items-center gap-3">
<button
type="button"
className="flex items-center gap-2 py-2 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors"
>
<Zap className="w-4 h-4" />
Test Connection
</button>
<Button variant="outline" type="button" onClick={() => navigate("..")} className="text-gray-600">
Cancel
</Button>
{activeTab >= 2 && (
<Button
type="button"
onClick={() => formik.submitForm()}
loading={formik.isSubmitting}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
Save Integration
</Button>
)}
</div>
</form>
</div>
</div>
</PageWrapper>
{/* Tabs */}
<div className="bg-white border-b border-gray-200 px-6">
<div className="flex gap-8">
{TABS.map((tab, i) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(i)}
className={`py-3.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === i
? "border-purple-600 text-purple-700"
: "border-transparent text-gray-500 hover:text-gray-900"
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Body */}
<form onSubmit={formik.handleSubmit} className="flex-1 px-6 py-6 flex gap-6 max-w-5xl mx-auto w-full">
<div className="flex-1 min-w-0">
{/* Tab 0: General Information */}
{activeTab === 0 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-5">
<h2 className="font-semibold text-gray-900 text-base">Integration Details</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Integration Name *</label>
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Amazon India Production"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && (
<p className="text-xs text-red-500 mt-1">{formik.errors.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Channel *</label>
<div className="relative">
<select
name="channel"
value={formik.values.channel}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.channel && Boolean(formik.errors.channel))}
>
<option value="">Select a channel from Channel Master...</option>
<option value="shopify_main">Shopify Main</option>
<option value="amazon_in">Amazon India</option>
<option value="woocommerce">WooCommerce</option>
</select>
</div>
<p className="text-xs text-gray-400 mt-1">Channels are defined in Channel Master. Integration Hub stores connection credentials separately.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Integration Type *</label>
<div className="grid grid-cols-3 gap-3">
{INTEGRATION_TYPES.map((type) => {
const Icon = type.icon;
const isSelected = formik.values.integrationType === type.id;
return (
<button
key={type.id}
type="button"
onClick={() => formik.setFieldValue("integrationType", type.id)}
className={`flex items-start gap-3 p-3 rounded-xl border-2 transition-all text-left ${
isSelected
? "border-purple-500 bg-purple-50"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${isSelected ? "bg-purple-100" : "bg-gray-100"}`}>
<Icon className={`w-4 h-4 ${isSelected ? "text-purple-600" : "text-gray-500"}`} />
</div>
<div>
<div className={`text-sm font-medium ${isSelected ? "text-purple-700" : "text-gray-900"}`}>{type.label}</div>
<div className="text-[11px] text-gray-500 mt-0.5 leading-tight">{type.sub}</div>
</div>
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Environment</label>
<div className="relative">
<select
name="environment"
value={formik.values.environment}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="production">Production</option>
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Status</label>
<div className="relative">
<select
name="status"
value={formik.values.status}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Describe the purpose of this integration..."
rows={3}
className={inputClass() + " resize-none"}
/>
</div>
</div>
)}
{/* Tab 1: Connection Configuration */}
{activeTab === 1 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900 text-base">Connection Configuration</h2>
<span className="px-2.5 py-1 text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200 rounded-lg">
{selectedType?.label || "E-Commerce"}
</span>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-100 rounded-xl text-sm text-blue-700">
<Info className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<p>Shopify / WooCommerce provide your store URL and private app access token. API credentials are encrypted at rest.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Store URL *</label>
<input
name="storeUrl"
value={formik.values.storeUrl}
onChange={formik.handleChange}
placeholder="https://your-store.myshopify.com"
className={inputClass()}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Access Token *</label>
<div className="relative">
<input
name="accessToken"
type={showToken ? "text" : "password"}
value={formik.values.accessToken}
onChange={formik.handleChange}
placeholder="shpat_xxxxxxxxxxx"
className={inputClass() + " pr-10"}
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">API Version</label>
<div className="relative">
<select
name="apiVersion"
value={formik.values.apiVersion}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="2025-01">2025-01 (Latest)</option>
<option value="2024-10">2024-10</option>
<option value="2024-07">2024-07</option>
</select>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Webhook Secret</label>
<input
name="webhookSecret"
value={formik.values.webhookSecret}
onChange={formik.handleChange}
placeholder="Optional webhook verification secret"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Shop Identifier</label>
<input
name="shopIdentifier"
value={formik.values.shopIdentifier}
onChange={formik.handleChange}
placeholder="your-store"
className={inputClass()}
/>
</div>
</div>
</div>
)}
{/* Tab 2: Synchronisation Settings */}
{activeTab === 2 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-6">
<h2 className="font-semibold text-gray-900 text-base">Synchronisation Settings</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Sync Direction</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: "pim_to_channel", label: "PIM → Channel", sub: "Publish product data to the external system", icon: "↑" },
{ id: "channel_to_pim", label: "Channel → PIM", sub: "Import data from external system into PIM", icon: "↓" },
{ id: "bidirectional", label: "Bidirectional", sub: "Sync data in both directions", icon: "↕" },
].map((d) => {
const isSelected = formik.values.syncDirection === d.id;
return (
<button
key={d.id}
type="button"
onClick={() => formik.setFieldValue("syncDirection", d.id)}
className={`flex flex-col items-center text-center p-4 rounded-xl border-2 transition-all gap-2 ${
isSelected ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300"
}`}
>
<span className={`text-xl font-bold ${isSelected ? "text-purple-600" : "text-gray-400"}`}>{d.icon}</span>
<div>
<div className={`text-sm font-semibold ${isSelected ? "text-purple-700" : "text-gray-900"}`}>{d.label}</div>
<div className="text-[11px] text-gray-500 mt-1">{d.sub}</div>
</div>
</button>
);
})}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Sync Frequency</label>
<div className="grid grid-cols-4 gap-3">
{[
{ id: "manual", label: "Manual", icon: Settings2 },
{ id: "hourly", label: "Hourly", icon: Clock },
{ id: "daily", label: "Daily", icon: Calendar },
{ id: "realtime", label: "Realtime", icon: Wifi },
].map((f) => {
const Icon = f.icon;
const isSelected = formik.values.syncFrequency === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => formik.setFieldValue("syncFrequency", f.id)}
className={`flex flex-col items-center text-center py-4 px-2 rounded-xl border-2 transition-all gap-2 ${
isSelected ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300"
}`}
>
<Icon className={`w-5 h-5 ${isSelected ? "text-purple-600" : "text-gray-400"}`} />
<span className={`text-sm font-medium ${isSelected ? "text-purple-700" : "text-gray-700"}`}>{f.label}</span>
</button>
);
})}
</div>
</div>
<div className="border border-gray-200 rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">Auto Retry on Failure</p>
<p className="text-xs text-gray-500 mt-0.5">Automatically retry failed sync records</p>
</div>
<button
type="button"
onClick={() => formik.setFieldValue("autoRetry", !formik.values.autoRetry)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
formik.values.autoRetry ? "bg-purple-600" : "bg-gray-200"
}`}
>
<span className={`inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform ${
formik.values.autoRetry ? "translate-x-5" : "translate-x-0"
}`} />
</button>
</div>
{formik.values.autoRetry && (
<div className="flex items-center gap-4 pt-2">
<label className="text-sm text-gray-700 font-medium">Retry Attempts</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => formik.setFieldValue("retryAttempts", Math.max(1, formik.values.retryAttempts - 1))}
className="w-8 h-8 rounded-lg border border-gray-200 flex items-center justify-center text-gray-600 hover:bg-gray-50 font-bold"
></button>
<span className="w-8 text-center font-bold text-gray-900">{formik.values.retryAttempts}</span>
<button
type="button"
onClick={() => formik.setFieldValue("retryAttempts", Math.min(10, formik.values.retryAttempts + 1))}
className="w-8 h-8 rounded-lg border border-gray-200 flex items-center justify-center text-gray-600 hover:bg-gray-50 font-bold"
>+</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Tab 3: Advanced Configuration */}
{activeTab === 3 && (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-gray-50 transition-colors"
>
<div>
<p className="font-medium text-gray-900">Localisation & Defaults</p>
<p className="text-xs text-gray-500 mt-0.5">Configure default locale, currency, and timezone for this integration</p>
</div>
<ChevronDown className="w-4 h-4 text-gray-400" />
</button>
</div>
{[
{ label: "Field Mapping", desc: "Map PIM attribute fields to external system field schemas" },
{ label: "Category Mapping", desc: "Map PIM category taxonomy to external system categories" },
{ label: "Transformation Rules", desc: "Define data transformation and enrichment rules during sync" },
].map((item) => (
<div key={item.label} className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 flex items-center gap-4">
<div className="w-9 h-9 bg-gray-100 rounded-lg flex items-center justify-center">
<Settings2 className="w-4 h-4 text-gray-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-gray-900">{item.label}</p>
<span className="px-2 py-0.5 text-[10px] font-bold uppercase bg-amber-50 text-amber-600 border border-amber-200 rounded">Coming Soon</span>
</div>
<p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>
</div>
</div>
))}
</div>
)}
{/* Tab Navigation */}
<div className="flex justify-between mt-6">
<Button
type="button"
variant="outline"
onClick={() => setActiveTab(Math.max(0, activeTab - 1))}
disabled={activeTab === 0}
className="text-gray-600"
>
Previous
</Button>
{activeTab < TABS.length - 1 ? (
<Button
type="button"
onClick={() => setActiveTab(Math.min(TABS.length - 1, activeTab + 1))}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
Next
</Button>
) : (
<Button
type="submit"
loading={formik.isSubmitting}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
Save Integration
</Button>
)}
</div>
</div>
{/* Right: Integration Summary */}
<IntegrationSummary />
</form>
</div>
);
}
@@ -1,6 +1,5 @@
import type { IntegrationCreateRequest } from '../types/integrations.types';
export const validateIntegration = (data: Partial<IntegrationCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Integration name is required';
return errors;
};
import * as Yup from 'yup';
export const integrationSchema = Yup.object().shape({
name: Yup.string().required('Integration name is required'),
});
+772 -160
View File
@@ -1,190 +1,802 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { Button } from '../../../components/customs/Button';
import { productService } from '../services/product.service';
import { validateProduct } from '../validation/product.schema';
import type { ProductStatus } from '../types/product.types';
import { ArrowLeft, Save } from 'lucide-react';
import { ArrowLeft, Box, LayoutGrid, Tags, Globe, Eye, Settings2, Save, Send, CheckCircle2, Copy, Upload, Image as ImageIcon, Video, FileText, Link as LinkIcon, Info, FolderTree, AlertCircle, RefreshCw } from 'lucide-react';
const STATUS_OPTIONS: { value: ProductStatus; label: string }[] = [
{ value: 'active', label: 'Active' },
{ value: 'pending', label: 'Pending' },
{ value: 'draft', label: 'Draft' },
{ value: 'disabled', label: 'Disabled' },
const MOCK_FAMILIES = [
{
id: 'f1',
name: 'Professional Laptops',
code: 'PROF-LAPTOP',
desc: 'Enterprise-grade computing devices with advanced specifications',
axes: 3, attrGroups: 3, channels: 4, workflow: 'Standard Approval'
},
{
id: 'f2',
name: 'Wireless Headphones',
code: 'AUDIO-WIRELESS',
desc: 'Premium audio devices with advanced connectivity',
axes: 2, attrGroups: 2, channels: 3, workflow: 'Fast Track'
},
{
id: 'f3',
name: 'Smart Home Devices',
code: 'SMART-HOME',
desc: 'IoT-enabled home automation products',
axes: 2, attrGroups: 2, channels: 2, workflow: 'Standard Approval'
}
];
const inputClass =
'w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-shadow';
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
const TABS = [
{ id: 'general', label: 'General', icon: Box },
{ id: 'attributes', label: 'Attributes', icon: LayoutGrid },
{ id: 'variants', label: 'Variants', icon: Tags },
{ id: 'assets', label: 'Assets', icon: Globe },
{ id: 'categories', label: 'Categories', icon: Settings2 },
{ id: 'channels', label: 'Channels', icon: Globe },
{ id: 'review', label: 'Review', icon: Eye }
];
export default function NewProduct() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const [step, setStep] = useState(1);
const [creationMethod, setCreationMethod] = useState<'family' | 'clone'>('family');
const [selectedFamily, setSelectedFamily] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('general');
const [form, setForm] = useState({
name: '',
sku: '',
productId: '',
category: '',
subcategory: '',
status: 'draft' as ProductStatus,
price: '',
stock: '',
// Form State
const [formData, setFormData] = useState({
name: '', code: 'PROF-LAPTOP', sku: '', barcode: '',
gtin: '', upc: '', ean: '',
brand: '', unit: '', country: '', hsn: '',
type: 'simple', status: 'Draft',
shortDesc: ''
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!id) return;
setLoading(true);
productService.getById(id).then((p) => {
if (p) {
setForm({
name: p.name,
sku: p.sku,
productId: p.productId,
category: p.category,
subcategory: p.subcategory,
status: p.status,
price: p.price.replace('$', ''),
stock: String(p.stock),
});
}
setLoading(false);
});
}, [id]);
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) setErrors((prev) => { const next = { ...prev }; delete next[field]; return next; });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = {
name: form.name,
sku: form.sku,
productId: form.productId,
category: form.category,
subcategory: form.subcategory,
status: form.status,
price: `$${form.price || '0.00'}`,
stock: parseInt(form.stock) || 0,
};
const validationErrors = validateProduct(payload);
if (Object.keys(validationErrors).length) { setErrors(validationErrors); return; }
setSaving(true);
try {
if (isEdit && id) {
await productService.update(id, payload);
} else {
await productService.create(payload);
}
navigate('/products');
} catch {
// handled inside service
} finally {
setSaving(false);
const handleNext = () => {
if (step === 1 && selectedFamily) {
setStep(2);
}
};
if (loading) {
return (
<PageWrapper>
<div className="flex justify-center items-center h-64 text-sm text-gray-400">Loading...</div>
</PageWrapper>
);
}
const inputClass = "w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
return (
<PageWrapper>
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('/products')}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-xl font-bold text-gray-900">{isEdit ? 'Edit Product' : 'Create Product'}</h1>
<p className="text-sm text-gray-500 mt-0.5">{isEdit ? 'Update product details' : 'Add a new product to the catalog'}</p>
<div className="bg-white border-b border-gray-200 px-6 py-4 sticky top-0 z-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => step === 2 ? setStep(1) : navigate('/products')}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-gray-900">{isEdit ? 'Edit Product' : 'Create Product'}</h1>
{step === 2 && (
<span className="px-2 py-0.5 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-md">
{MOCK_FAMILIES.find(f => f.id === selectedFamily)?.name}
</span>
)}
</div>
</div>
{step === 2 && (
<div className="flex items-center gap-3">
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
<Save className="w-4 h-4" /> Save Draft
</button>
<button className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50">
<Send className="w-4 h-4" /> Submit for Review
</button>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
Publish
</button>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Basic Information</h2>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className={labelClass}>Product Name *</label>
<input className={inputClass} value={form.name} onChange={set('name')} placeholder="e.g. Wireless Headphones Pro" />
{errors.name && <p className={errorClass}>{errors.name}</p>}
<div className="flex-1 max-w-[1400px] w-full mx-auto px-6 py-6">
{step === 1 && (
<div className="max-w-4xl mx-auto">
<p className="text-sm text-gray-500 mb-8">Select a product family to get started</p>
<h2 className="text-sm font-semibold text-gray-900 mb-3">Choose Creation Method</h2>
<div className="flex gap-4 mb-8">
<button
onClick={() => setCreationMethod('family')}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 ${creationMethod === 'family' ? 'border-purple-500 bg-purple-50/50' : 'border-gray-200 bg-white hover:border-purple-200'}`}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${creationMethod === 'family' ? 'border-purple-600' : 'border-gray-300'}`}>
{creationMethod === 'family' && <div className="w-2 h-2 rounded-full bg-purple-600" />}
</div>
<div>
<div className="font-semibold text-gray-900 text-sm">Create from Family</div>
<div className="text-xs text-gray-500">Start with family template and variant structure</div>
</div>
</button>
<button
onClick={() => setCreationMethod('clone')}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 opacity-50 cursor-not-allowed border-gray-200 bg-gray-50`}
>
<div className="w-4 h-4 rounded-full border-2 border-gray-300 flex items-center justify-center" />
<div>
<div className="font-semibold text-gray-900 text-sm">Clone Existing Product</div>
<div className="text-xs text-gray-500">Copy attributes from existing product</div>
</div>
</button>
</div>
<div>
<label className={labelClass}>SKU *</label>
<input className={inputClass} value={form.sku} onChange={set('sku')} placeholder="e.g. WH-PRO-001" />
{errors.sku && <p className={errorClass}>{errors.sku}</p>}
</div>
<div>
<label className={labelClass}>Product ID *</label>
<input className={inputClass} value={form.productId} onChange={set('productId')} placeholder="e.g. P1001" />
{errors.productId && <p className={errorClass}>{errors.productId}</p>}
<h2 className="text-sm font-semibold text-gray-900 mb-3">Product Family</h2>
<div className="space-y-4">
{MOCK_FAMILIES.map(family => (
<div
key={family.id}
onClick={() => { setSelectedFamily(family.id); handleNext(); }}
className="bg-white border border-gray-200 rounded-xl p-5 hover:border-purple-400 hover:shadow-md transition-all cursor-pointer"
>
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-lg font-bold text-gray-900">{family.name}</h3>
<p className="text-xs font-medium text-gray-500">{family.code}</p>
</div>
<div className="w-8 h-8 rounded bg-gray-100 flex items-center justify-center">
<Box className="w-4 h-4 text-gray-500" />
</div>
</div>
<p className="text-sm text-gray-600 mb-4">{family.desc}</p>
<div className="grid grid-cols-4 gap-4 text-sm mb-4">
<div>
<div className="text-gray-500 text-xs">Variant Axes</div>
<div className="font-semibold text-gray-900">{family.axes}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Attribute Groups</div>
<div className="font-semibold text-gray-900">{family.attrGroups}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Channels</div>
<div className="font-semibold text-gray-900">{family.channels}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Workflow</div>
<div className="font-semibold text-gray-900">{family.workflow}</div>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-emerald-600 font-medium">
<CheckCircle2 className="w-3.5 h-3.5" />
Inherited: Variant Axes, Attribute Groups, Channels, Workflow
</div>
</div>
))}
</div>
</div>
</div>
)}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Classification</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Category *</label>
<input className={inputClass} value={form.category} onChange={set('category')} placeholder="e.g. Electronics" />
{errors.category && <p className={errorClass}>{errors.category}</p>}
{step === 2 && (
<div className="flex flex-col h-[calc(100vh-140px)]">
<p className="text-sm text-gray-500 mb-4">Fill in product details across all tabs</p>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200 mb-6 shrink-0">
{TABS.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
isActive ? 'border-purple-600 text-purple-700 bg-purple-50/50' : 'border-transparent text-gray-500 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
<div>
<label className={labelClass}>Subcategory</label>
<input className={inputClass} value={form.subcategory} onChange={set('subcategory')} placeholder="e.g. Audio" />
</div>
<div className="col-span-2">
<label className={labelClass}>Status</label>
<select className={inputClass} value={form.status} onChange={set('status')}>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<div className="flex gap-8 flex-1 min-h-0 overflow-hidden">
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto pr-2 pb-10">
{activeTab === 'general' && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-8">
{/* Basic Information */}
<div>
<div className="flex items-center gap-2 mb-4">
<CheckCircle2 className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-gray-900">Basic Information</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="col-span-2">
<label className={labelClass}>Product Name *</label>
<input
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
placeholder="Enter product name"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Product Code *</label>
<input value={formData.code} readOnly className={`${inputClass} bg-gray-50 text-gray-500`} />
</div>
<div>
<label className={labelClass}>Status</label>
<select value={formData.status} onChange={e => setFormData({...formData, status: e.target.value})} className={inputClass}>
<option>Draft</option>
<option>Pending Review</option>
<option>Active</option>
</select>
</div>
</div>
</div>
{/* Identification */}
<div>
<div className="flex items-center gap-2 mb-4">
<Tags className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-gray-900">Identification & Barcodes</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>SKU</label>
<input placeholder="Stock Keeping Unit" className={inputClass} />
</div>
<div>
<label className={labelClass}>Barcode</label>
<input placeholder="Barcode" className={inputClass} />
</div>
<div>
<label className={labelClass}>GTIN</label>
<input placeholder="Global Trade Item Number" className={inputClass} />
</div>
<div>
<label className={labelClass}>UPC</label>
<input placeholder="Universal Product Code" className={inputClass} />
</div>
<div className="col-span-2">
<label className={labelClass}>EAN</label>
<input placeholder="European Article Number" className={inputClass} />
</div>
</div>
</div>
{/* Classification */}
<div>
<div className="flex items-center gap-2 mb-4">
<LayoutGrid className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-gray-900">Classification</h3>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Brand</label>
<select className={inputClass}><option>Select brand</option></select>
</div>
<div>
<label className={labelClass}>Unit</label>
<select className={inputClass}><option>Select unit</option></select>
</div>
<div>
<label className={labelClass}>Country of Origin</label>
<input placeholder="e.g., USA, China, Germany" className={inputClass} />
</div>
<div>
<label className={labelClass}>HSN Code</label>
<input placeholder="Harmonized System Code" className={inputClass} />
</div>
</div>
</div>
{/* Product Type */}
<div>
<h3 className="font-semibold text-gray-900 mb-4">Product Type</h3>
<div className="flex gap-4">
<label className="flex items-center gap-3 p-4 border border-purple-200 bg-purple-50 rounded-xl cursor-pointer flex-1">
<input type="radio" name="type" value="simple" checked={formData.type === 'simple'} onChange={() => setFormData({...formData, type: 'simple'})} className="w-4 h-4 text-purple-600 focus:ring-purple-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Simple Product</div>
<div className="text-xs text-gray-500">Single product without variations</div>
</div>
</label>
<label className="flex items-center gap-3 p-4 border border-gray-200 bg-white hover:border-purple-200 rounded-xl cursor-pointer flex-1">
<input type="radio" name="type" value="variant" checked={formData.type === 'variant'} onChange={() => setFormData({...formData, type: 'variant'})} className="w-4 h-4 text-purple-600 focus:ring-purple-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Variant Product</div>
<div className="text-xs text-gray-500">Product with multiple variations (size, color, etc.)</div>
</div>
</label>
</div>
</div>
</div>
)}
{activeTab === 'attributes' && (
<div className="space-y-6">
{/* Core Dimensions */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<h3 className="font-semibold text-gray-900">Core Dimensions</h3>
<span className="px-2 py-0.5 text-[10px] font-medium bg-blue-50 text-blue-700 border border-blue-200 rounded">Platform</span>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Net Weight <span className="text-red-500">*</span></label>
<input placeholder="Enter net weight" className={inputClass} />
</div>
<div>
<label className={labelClass}>Gross Weight <span className="text-red-500">*</span></label>
<input placeholder="Enter gross weight" className={inputClass} />
</div>
<div>
<label className={labelClass}>Width <span className="text-red-500">*</span></label>
<input placeholder="Enter width" className={inputClass} />
</div>
<div>
<label className={labelClass}>Height <span className="text-red-500">*</span></label>
<input placeholder="Enter height" className={inputClass} />
</div>
<div>
<label className={labelClass}>Depth <span className="text-red-500">*</span></label>
<input placeholder="Enter depth" className={inputClass} />
</div>
</div>
</div>
{/* SEO & Marketing */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<h3 className="font-semibold text-gray-900">SEO & Marketing</h3>
<span className="px-2 py-0.5 text-[10px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-200 rounded">Category</span>
</div>
<div className="space-y-6">
<div>
<label className={labelClass}>Meta Title</label>
<input placeholder="Enter meta title" className={inputClass} />
</div>
<div>
<label className={labelClass}>Meta Description</label>
<textarea placeholder="Enter meta description" rows={3} className={`${inputClass} resize-none`} />
</div>
<div>
<label className={labelClass}>Keywords</label>
<input placeholder="Enter keywords" className={inputClass} />
</div>
</div>
</div>
{/* Technical Specifications */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<h3 className="font-semibold text-gray-900">Technical Specifications</h3>
<span className="px-2 py-0.5 text-[10px] font-medium bg-purple-50 text-purple-700 border border-purple-200 rounded">Family</span>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Processor <span className="text-red-500">*</span></label>
<input placeholder="Enter processor" className={inputClass} />
</div>
<div>
<label className={labelClass}>RAM <span className="text-red-500">*</span></label>
<select className={inputClass}><option>Select ram</option></select>
</div>
<div>
<label className={labelClass}>Storage Type <span className="text-red-500">*</span></label>
<select className={inputClass}><option>Select storage type</option></select>
</div>
<div>
<label className={labelClass}>Screen Size <span className="text-red-500">*</span></label>
<input placeholder="Enter screen size" className={inputClass} />
</div>
<div>
<label className={labelClass}>Resolution</label>
<input placeholder="Enter resolution" className={inputClass} />
</div>
</div>
</div>
</div>
)}
{activeTab === 'variants' && (
<div className="space-y-6">
<div className="bg-purple-50 border border-purple-100 rounded-xl p-4 flex items-start justify-between">
<div className="flex items-start gap-3">
<Tags className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<h4 className="font-semibold text-purple-900 text-sm">Variant Axes Inherited from Product Family</h4>
<p className="text-xs text-purple-700 mt-1 max-w-2xl">Define values below for each inherited axis to generate product variants. Variant axes (dimensions) come from the selected family values are defined here at the product level.</p>
</div>
</div>
<span className="px-2.5 py-1 bg-white text-purple-700 text-xs font-bold rounded-lg border border-purple-200">
3 Inherited Axes
</span>
</div>
{['Storage', 'Memory', 'Color'].map(axis => (
<div key={axis} className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<Box className="w-4 h-4 text-purple-500" /> {axis}
</div>
<div className="flex gap-3 text-xs font-medium text-gray-500">
<button className="flex items-center gap-1 hover:text-gray-900"><Copy className="w-3 h-3" /> Bulk Paste</button>
<button className="flex items-center gap-1 hover:text-gray-900"><Globe className="w-3 h-3" /> Import CSV</button>
</div>
</div>
<div className="p-8 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mb-3">
<Box className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 mb-4">No values defined yet. Add values to enable variant generation.</p>
<button className="text-sm font-medium text-purple-600 border border-purple-200 px-4 py-2 rounded-lg hover:bg-purple-50 border-dashed">
+ Add Value
</button>
</div>
</div>
))}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h3 className="font-semibold text-gray-900 mb-4">Variant Preview</h3>
<div className="flex gap-4 mb-4">
<div className="flex-1 border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Storage</div>
<div className="text-lg font-bold text-gray-900">0 <span className="text-xs font-normal text-gray-500">values</span></div>
</div>
<div className="flex-1 border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Memory</div>
<div className="text-lg font-bold text-gray-900">0 <span className="text-xs font-normal text-gray-500">values</span></div>
</div>
<div className="flex-1 border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Color</div>
<div className="text-lg font-bold text-gray-900">0 <span className="text-xs font-normal text-gray-500">values</span></div>
</div>
</div>
<p className="text-xs text-gray-500">Define values above to see a preview</p>
</div>
<div className="flex justify-center pt-4">
<button className="bg-purple-300 text-white px-8 py-2.5 rounded-lg font-medium text-sm cursor-not-allowed">
Generate Variants
</button>
</div>
</div>
)}
{activeTab === 'assets' && (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h3 className="font-semibold text-gray-900 mb-4">Product Digital Assets</h3>
<div className="border-2 border-dashed border-gray-200 rounded-xl p-12 flex flex-col items-center justify-center text-center mb-8">
<Upload className="w-10 h-10 text-gray-400 mb-4" />
<h4 className="text-gray-900 font-semibold mb-1">Drag & Drop Files Here</h4>
<p className="text-sm text-gray-500 mb-4">or click to browse</p>
<button className="flex items-center gap-2 border border-gray-200 rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors bg-white">
<Upload className="w-4 h-4" /> Choose Files
</button>
</div>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">ASSET TYPES</h4>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-xl">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
<ImageIcon className="w-5 h-5 text-blue-500" />
</div>
<div>
<div className="font-semibold text-sm text-gray-900">Images</div>
<div className="text-xs text-gray-500">JPG, PNG, WebP</div>
</div>
</div>
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-xl">
<div className="w-10 h-10 rounded-lg bg-purple-50 flex items-center justify-center shrink-0">
<Video className="w-5 h-5 text-purple-500" />
</div>
<div>
<div className="font-semibold text-sm text-gray-900">Videos</div>
<div className="text-xs text-gray-500">MP4, WebM</div>
</div>
</div>
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-xl">
<div className="w-10 h-10 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
<FileText className="w-5 h-5 text-emerald-500" />
</div>
<div>
<div className="font-semibold text-sm text-gray-900">Documents</div>
<div className="text-xs text-gray-500">PDF, DOC, DOCX</div>
</div>
</div>
<div className="flex items-center gap-4 p-4 border border-gray-200 rounded-xl">
<div className="w-10 h-10 rounded-lg bg-orange-50 flex items-center justify-center shrink-0">
<LinkIcon className="w-5 h-5 text-orange-500" />
</div>
<div>
<div className="font-semibold text-sm text-gray-900">External URLs</div>
<div className="text-xs text-gray-500">YouTube, Vimeo</div>
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-blue-900 text-sm">No assets uploaded yet</h4>
<p className="text-xs text-blue-700 mt-1">Add product images, videos, and documents to improve completeness</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'categories' && (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h3 className="font-semibold text-gray-900 mb-1">Category Assignment</h3>
<p className="text-sm text-gray-500 mb-6">Assign this product to multiple category trees for different channels</p>
<div className="space-y-4">
<div className="border border-gray-200 rounded-xl p-4 flex items-center justify-between">
<div>
<div className="font-semibold text-sm text-gray-900 mb-1">E-Commerce Tree</div>
<div className="text-sm text-gray-500">Electronics / Computers / Laptops</div>
</div>
<button className="flex items-center gap-2 border border-gray-200 rounded-lg px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
<FolderTree className="w-4 h-4" /> Change
</button>
</div>
<div className="border border-gray-200 rounded-xl p-4 flex items-center justify-between">
<div>
<div className="font-semibold text-sm text-gray-900 mb-1">POS Tree</div>
<div className="text-sm text-gray-500">Electronics / Laptops</div>
</div>
<button className="flex items-center gap-2 border border-gray-200 rounded-lg px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
<FolderTree className="w-4 h-4" /> Change
</button>
</div>
<div className="border border-gray-200 rounded-xl p-4 flex items-center justify-between">
<div>
<div className="font-semibold text-sm text-gray-900 mb-1">WMS Tree</div>
<div className="text-sm text-gray-500">Finished Goods / Electronics</div>
</div>
<button className="flex items-center gap-2 border border-gray-200 rounded-lg px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
<FolderTree className="w-4 h-4" /> Change
</button>
</div>
<button className="w-full border border-gray-200 rounded-xl p-4 flex items-center justify-center gap-2 text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors">
+ Add Another Category Tree
</button>
</div>
</div>
</div>
)}
{activeTab === 'channels' && (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-semibold text-gray-900 mb-1">Channel Publishing</h3>
<p className="text-sm text-gray-500">Enable channels to publish this product</p>
</div>
<span className="px-2 py-0.5 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-md">Inherited from Family</span>
</div>
<div className="space-y-4">
{[
{ name: 'Shopify', status: 'Ready for publishing', complete: '4%' },
{ name: 'Amazon', status: 'Ready for publishing', complete: '0%' },
{ name: 'POS', status: 'Ready for publishing', complete: '0%' },
{ name: 'WMS', status: 'Ready for publishing', complete: '8%' },
].map((channel, i) => (
<div key={i} className="border border-gray-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" />
<div>
<div className="font-semibold text-sm text-gray-900">{channel.name}</div>
<div className="text-xs text-gray-500">{channel.status}</div>
</div>
</label>
<div className="text-right">
<div className="font-semibold text-sm text-gray-900">{channel.complete}</div>
<div className="text-xs text-gray-500">Complete</div>
</div>
</div>
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500 rounded-full" style={{ width: channel.complete }} />
</div>
</div>
))}
</div>
</div>
</div>
)}
{activeTab === 'review' && (
<div className="space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h3 className="font-semibold text-gray-900 mb-4">Product Summary</h3>
<div className="grid grid-cols-2 gap-y-6 gap-x-12">
<div>
<div className="text-xs text-gray-500 mb-1">Product Name</div>
<div className="font-medium text-sm text-gray-900">{formData.name || 'Not set'}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Product Code</div>
<div className="font-medium text-sm text-gray-900">{formData.code}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Product Family</div>
<div className="font-medium text-sm text-gray-900">Professional Laptops</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Brand</div>
<div className="font-medium text-sm text-gray-900">{formData.brand || 'Not set'}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Status</div>
<span className="px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md">draft</span>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Product Type</div>
<div className="font-medium text-sm text-gray-900 capitalize">{formData.type}</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-sm text-gray-900">Attributes Filled</h3>
<LayoutGrid className="w-4 h-4 text-purple-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-4">0 / 45</div>
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500 rounded-full w-0" />
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-sm text-gray-900">Variants</h3>
<LayoutGrid className="w-4 h-4 text-purple-600" />
</div>
<div className="text-2xl font-bold text-gray-900">0</div>
<div className="text-xs text-gray-500">SKUs generated</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-sm text-gray-900">Assets</h3>
<ImageIcon className="w-4 h-4 text-purple-600" />
</div>
<div className="text-2xl font-bold text-gray-900">0</div>
<div className="text-xs text-gray-500">Files uploaded</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex justify-between items-start mb-2">
<h3 className="font-semibold text-sm text-gray-900">Categories</h3>
<FolderTree className="w-4 h-4 text-purple-600" />
</div>
<div className="text-2xl font-bold text-gray-900">3</div>
<div className="text-xs text-gray-500">Trees assigned</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h3 className="font-semibold text-sm text-gray-900 mb-6">Channel Completeness</h3>
<div className="space-y-4">
{[
{ name: 'Shopify', complete: '2%' },
{ name: 'Amazon', complete: '0%' },
{ name: 'POS', complete: '0%' },
{ name: 'WMS', complete: '5%' },
].map((ch, i) => (
<div key={i} className="flex items-center gap-4">
<div className="w-20 text-xs font-medium text-gray-700 flex items-center gap-2">
<RefreshCw className="w-3 h-3 text-gray-400" /> {ch.name}
</div>
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-purple-500 rounded-full" style={{ width: ch.complete }} />
</div>
<div className="w-8 text-right text-xs font-medium text-gray-900">{ch.complete}</div>
</div>
))}
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-sm text-gray-900">Overall Completeness</h3>
<div className="text-xl font-bold text-purple-700">2%</div>
</div>
<div className="h-2.5 w-full bg-gray-100 rounded-full overflow-hidden mb-6">
<div className="h-full bg-purple-600 rounded-full w-[2%]" />
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex gap-3">
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0" />
<div>
<h4 className="font-medium text-sm text-amber-900">Product not ready for publishing</h4>
<p className="text-xs text-amber-700 mt-0.5">Complete at least 90% of required fields to publish this product</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="w-72 shrink-0 flex flex-col gap-6 overflow-y-auto pr-2">
{/* Completeness Score */}
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-gray-900 text-sm mb-1">Completeness Score</h3>
<p className="text-xs text-gray-500 mb-6">Track your product completion status</p>
<div className="flex justify-center mb-6">
<div className="relative w-32 h-32">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="#f3f4f6" strokeWidth="8" />
<circle cx="50" cy="50" r="45" fill="none" stroke="#9333ea" strokeWidth="8" strokeDasharray="283" strokeDashoffset="277" strokeLinecap="round" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-gray-900">2%</span>
<span className="text-[10px] text-gray-500 uppercase tracking-wider font-semibold">Complete</span>
</div>
</div>
</div>
<div className="space-y-4 text-sm">
<div className="flex justify-between items-center border-b border-gray-100 pb-2">
<span className="text-gray-600">Variants Count</span>
<span className="font-bold text-gray-900">0</span>
</div>
<div className="flex justify-between items-center border-b border-gray-100 pb-2">
<span className="text-gray-600">Assets Count</span>
<span className="font-bold text-gray-900">0</span>
</div>
<div className="flex justify-between items-center border-b border-purple-600 pb-2">
<span className="text-gray-900 font-medium">Categories Count</span>
<span className="font-bold text-gray-900">3</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Channels Count</span>
<span className="font-bold text-gray-900">0</span>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-gray-900 text-xs tracking-wider uppercase mb-4 text-gray-500">Quick Actions</h3>
<div className="space-y-2">
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 transition-colors">
<Save className="w-4 h-4" /> Save Draft
</button>
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-sm font-medium text-gray-700 transition-colors">
<Send className="w-4 h-4" /> Submit for Review
</button>
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
Publish Product
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Pricing &amp; Stock</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Price ($)</label>
<input type="number" step="0.01" className={inputClass} value={form.price} onChange={set('price')} placeholder="0.00" />
</div>
<div>
<label className={labelClass}>Stock</label>
<input type="number" className={inputClass} value={form.stock} onChange={set('stock')} placeholder="0" />
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button variant="outline" size="md" onClick={() => navigate('/products')}>Cancel</Button>
<Button variant="primary" size="md" icon={<Save className="w-4 h-4" />} disabled={saving}>
{saving ? 'Saving…' : isEdit ? 'Update Product' : 'Create Product'}
</Button>
</div>
</form>
</PageWrapper>
)}
</div>
</div>
);
}
@@ -1,12 +1,12 @@
import type { ProductCreateRequest } from '../types/product.types';
import * as Yup from 'yup';
export const validateProduct = (data: Partial<ProductCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Product name is required';
if (!data.sku?.trim()) errors.sku = 'SKU is required';
if (!data.productId?.trim()) errors.productId = 'Product ID is required';
if (!data.category?.trim()) errors.category = 'Category is required';
return errors;
};
export const productSchema = Yup.object().shape({
name: Yup.string().required('Product name is required'),
sku: Yup.string().required('SKU is required'),
productId: Yup.string().required('Product ID is required'),
category: Yup.string().required('Category is required'),
subcategory: Yup.string(),
status: Yup.string().oneOf(['active', 'pending', 'draft', 'disabled']),
price: Yup.string(),
stock: Yup.number().integer().min(0, 'Stock cannot be negative'),
});
+102 -42
View File
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useReport } from "../hook/useReport";
import { validateReport } from "../validation/reports.schema";
import { reportSchema } from "../validation/reports.schema";
import { reportsService } from "../services/reports.service";
import type { ReportCreateRequest } from "../types/reports.types";
import { ArrowLeft, Save } from "lucide-react";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewReport() {
const navigate = useNavigate();
@@ -13,57 +20,110 @@ export default function NewReport() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useReport();
const [form, setForm] = useState<Partial<ReportCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: reportSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as ReportCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
reportsService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateReport(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as ReportCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Report</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('..')}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Report' : 'Create Report'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update report details' : 'Configure a new report'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Report Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Monthly Sales Report"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Report' : 'Create Report'}
</Button>
</div>
</form>
</PageWrapper>
);
}
+339 -45
View File
@@ -1,55 +1,349 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { TrendingUp, Activity, Box, Users } from "lucide-react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
LineChart,
Line,
} from "recharts";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useReport } from "../hook/useReport";
import type { Report } from "../types/reports.types";
import { KPIGrid } from "../../../components/customs/KPI";
const publicationData = [
{ name: "Jan", created: 1200, approved: 1100 },
{ name: "Feb", created: 1600, approved: 1500 },
{ name: "Mar", created: 1400, approved: 1300 },
{ name: "Apr", created: 1900, approved: 1800 },
{ name: "May", created: 2200, approved: 2100 },
{ name: "Jun", created: 2400, approved: 2350 },
];
const categoryData = [
{ name: "Electronics", value: 3500 },
{ name: "Apparel", value: 2800 },
{ name: "Home & Garden", value: 2100 },
{ name: "Health & Wellness", value: 1600 },
{ name: "Sports", value: 1200 },
];
const weeklyData = [
{ name: "Mon", created: 45, published: 38 },
{ name: "Tue", created: 52, published: 42 },
{ name: "Wed", created: 48, published: 44 },
{ name: "Thu", created: 62, published: 52 },
{ name: "Fri", created: 55, published: 48 },
{ name: "Sat", created: 32, published: 28 },
{ name: "Sun", created: 28, published: 25 },
];
const topContributors = [
{ init: "SJ", color: "bg-purple-600", name: "Sarah Johnson", role: "Product Manager", count: 142 },
{ init: "MC", color: "bg-purple-600", name: "Mike Chen", role: "Catalog Specialist", count: 128 },
{ init: "EW", color: "bg-purple-600", name: "Emma Wilson", role: "Content Editor", count: 115 },
{ init: "DL", color: "bg-purple-600", name: "David Lee", role: "Data Analyst", count: 98 },
{ init: "JD", color: "bg-purple-600", name: "John Doe", role: "Product Manager", count: 87 },
];
export default function ReportList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useReport();
const [, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Report) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const columns = [
{ key: "name", label: "Name", sortable: true },
{ key: "status", label: "Status", sortable: true },
{ key: "createdAt", label: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
];
const actions = (item: Report) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
<p className="text-sm text-gray-500">Manage your reports</p>
<div className="mb-6">
<div className="text-sm text-gray-500 mb-1 flex items-center gap-1">
Dashboard <span className="text-gray-300"></span> <span className="text-gray-900">Reports & Analytics</span>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Report
</Button>
<h2 className="text-2xl font-bold text-gray-900">Reports & Analytics</h2>
<p className="text-sm text-gray-500">Comprehensive insights into your product catalog performance</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* KPI Cards */}
<KPIGrid className="mb-6 grid-cols-1 md:grid-cols-4">
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">CATALOG GROWTH</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
<TrendingUp className="w-4 h-4 text-purple-600" />
</div>
</div>
<div>
<h3 className="text-3xl font-bold text-gray-900">+18.2%</h3>
<p className="text-xs text-gray-500 mt-1">vs. last month</p>
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">APPROVAL RATE</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
<Activity className="w-4 h-4 text-purple-600" />
</div>
</div>
<div>
<div className="flex items-baseline gap-2">
<h3 className="text-3xl font-bold text-gray-900">96.8%</h3>
<span className="text-sm font-medium text-emerald-600"> 2.4%</span>
</div>
<p className="text-xs text-gray-500 mt-1">vs. last month</p>
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">AVG. TIME TO PUBLISH</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
<Box className="w-4 h-4 text-purple-600" />
</div>
</div>
<div>
<h3 className="text-3xl font-bold text-gray-900">2.4 days</h3>
<p className="text-xs text-gray-500 mt-1">From creation to live</p>
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-4">
<span className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">ACTIVE CONTRIBUTORS</span>
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center">
<Users className="w-4 h-4 text-purple-600" />
</div>
</div>
<div>
<div className="flex items-baseline gap-2">
<h3 className="text-3xl font-bold text-gray-900">24</h3>
<span className="text-sm font-medium text-emerald-600"> 8.3%</span>
</div>
<p className="text-xs text-gray-500 mt-1">This month</p>
</div>
</div>
</KPIGrid>
{/* Main Charts: Row 1 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Publication Metrics (AreaChart) - Spans 2 columns */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm lg:col-span-2 flex flex-col">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="font-bold text-gray-900 text-lg">Publication Metrics</h3>
<p className="text-sm text-gray-500">Products created vs approved</p>
</div>
<select className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-700 bg-white shadow-sm outline-none focus:border-purple-500">
<option>Last 6 months</option>
<option>Last 30 days</option>
<option>This year</option>
</select>
</div>
<div className="w-full h-[320px] min-h-[320px] mt-4">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={publicationData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorCreated" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#9333ea" stopOpacity={0.2} />
<stop offset="95%" stopColor="#9333ea" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorApproved" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} />
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}
itemStyle={{ fontSize: '14px', fontWeight: 500 }}
/>
<Area type="monotone" dataKey="created" name="Created" stroke="#9333ea" strokeWidth={2} fillOpacity={1} fill="url(#colorCreated)" />
<Area type="monotone" dataKey="approved" name="Approved" stroke="#10b981" strokeWidth={2} fillOpacity={1} fill="url(#colorApproved)" />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-4 text-xs text-gray-500 font-medium">
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-purple-600"></div> Created</div>
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-emerald-500"></div> Approved</div>
</div>
</div>
{/* Products by Category (BarChart) - Spans 1 column */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex flex-col">
<div className="mb-6">
<h3 className="font-bold text-gray-900 text-lg">Products by Category</h3>
<p className="text-sm text-gray-500">Top 5 categories</p>
</div>
<div className="w-full h-[320px] min-h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart layout="vertical" data={categoryData} margin={{ top: 0, right: 20, left: 20, bottom: 0 }} barSize={32}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#f3f4f6" />
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} />
<YAxis dataKey="name" type="category" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} width={100} />
<Tooltip
cursor={{ fill: '#f9fafb' }}
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}
/>
<Bar dataKey="value" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Main Charts: Row 2 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Weekly Activity (LineChart) - Spans 2 columns */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm lg:col-span-2 flex flex-col">
<div className="mb-6">
<h3 className="font-bold text-gray-900 text-lg">Weekly Activity</h3>
<p className="text-sm text-gray-500">Product creation and publication timeline</p>
</div>
<div className="w-full h-[320px] min-h-[320px] mt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={weeklyData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} />
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e5e7eb', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}
itemStyle={{ fontSize: '14px', fontWeight: 500 }}
/>
<Line type="monotone" dataKey="created" name="Created" stroke="#9333ea" strokeWidth={2} dot={{ r: 4, strokeWidth: 2 }} activeDot={{ r: 6 }} />
<Line type="monotone" dataKey="published" name="Published" stroke="#10b981" strokeWidth={2} dot={{ r: 4, strokeWidth: 2 }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-4 text-xs text-gray-500 font-medium">
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-purple-600"></div> Created</div>
<div className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-emerald-500"></div> Published</div>
</div>
</div>
{/* Top Contributors - Spans 1 column */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex flex-col">
<div className="mb-6">
<h3 className="font-bold text-gray-900 text-lg">Top Contributors</h3>
</div>
<div className="flex flex-col gap-4 mt-2">
{topContributors.map((user, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium ${user.color}`}>
{user.init}
</div>
<div>
<div className="font-medium text-gray-900 text-sm">{user.name}</div>
<div className="text-xs text-gray-500">{user.role}</div>
</div>
</div>
<div className="font-bold text-gray-900">{user.count}</div>
</div>
))}
</div>
</div>
</div>
{/* Bottom Row Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Completeness Distribution */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<h3 className="font-bold text-gray-900 mb-6">Completeness Distribution</h3>
<div className="flex flex-col gap-4">
<div>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-gray-600">100% Complete</span>
<span className="font-medium text-gray-900">4,280</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div className="bg-emerald-500 h-2 rounded-full" style={{ width: '70%' }}></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-gray-600">80-99% Complete</span>
<span className="font-medium text-gray-900">1,450</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div className="bg-amber-500 h-2 rounded-full" style={{ width: '25%' }}></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-gray-600">Below 80%</span>
<span className="font-medium text-gray-900">540</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div className="bg-red-500 h-2 rounded-full" style={{ width: '10%' }}></div>
</div>
</div>
</div>
</div>
{/* Workflow Efficiency */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm flex flex-col justify-center">
<h3 className="font-bold text-gray-900 mb-6">Workflow Efficiency</h3>
<div className="flex flex-col gap-6">
<div>
<div className="text-sm text-gray-500 mb-1">Avg. Review Time</div>
<div className="flex items-baseline gap-2">
<div className="text-3xl font-bold text-gray-900">4.2 hours</div>
</div>
<div className="text-xs text-emerald-500 font-medium mt-1">-12% vs last month</div>
</div>
<div className="h-[1px] w-full bg-gray-100"></div>
<div>
<div className="text-sm text-gray-500 mb-1">Approval Rate</div>
<div className="flex items-baseline gap-2">
<div className="text-3xl font-bold text-gray-900">96.8%</div>
</div>
<div className="text-xs text-emerald-500 font-medium mt-1">+2.4% vs last month</div>
</div>
</div>
</div>
{/* Channel Performance */}
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<h3 className="font-bold text-gray-900 mb-6">Channel Performance</h3>
<div className="flex flex-col gap-4">
<div>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-gray-600">E-commerce</span>
<div className="text-right">
<span className="text-gray-900 mr-2">99.8%</span>
<span className="text-gray-400 text-xs">4200</span>
</div>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div className="bg-emerald-500 h-2 rounded-full" style={{ width: '99%' }}></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-gray-600">POS</span>
<div className="text-right">
<span className="text-gray-900 mr-2">98.5%</span>
<span className="text-gray-400 text-xs">3100</span>
</div>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div className="bg-emerald-500 h-2 rounded-full" style={{ width: '95%' }}></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-gray-600">Mobile App</span>
<div className="text-right">
<span className="text-gray-900 mr-2">100%</span>
<span className="text-gray-400 text-xs">1900</span>
</div>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div className="bg-emerald-500 h-2 rounded-full" style={{ width: '100%' }}></div>
</div>
</div>
</div>
</div>
</div>
</PageWrapper>
);
@@ -1,6 +1,5 @@
import type { ReportCreateRequest } from '../types/reports.types';
export const validateReport = (data: Partial<ReportCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Report name is required';
return errors;
};
import * as Yup from 'yup';
export const reportSchema = Yup.object().shape({
name: Yup.string().required('Report name is required'),
});
+103 -42
View File
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useScope } from "../hook/useScope";
import { validateScope } from "../validation/scopes.schema";
import { scopeSchema } from "../validation/scopes.schema";
import { scopesService } from "../services/scopes.service";
import type { ScopeCreateRequest } from "../types/scopes.types";
import { ArrowLeft, Save } from "lucide-react";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewScope() {
const navigate = useNavigate();
@@ -13,57 +20,111 @@ export default function NewScope() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useScope();
const [form, setForm] = useState<Partial<ScopeCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: scopeSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as ScopeCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
scopesService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateScope(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as ScopeCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Scope</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('..')}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Scope' : 'Create Scope'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update scope details' : 'Configure a new scope'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Scope Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Global"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Scope' : 'Create Scope'}
</Button>
</div>
</form>
</PageWrapper>
);
}
+5 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -10,14 +10,11 @@ import type { Scope } from "../types/scopes.types";
export default function ScopeList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useScope();
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Scope) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
@@ -25,9 +22,9 @@ export default function ScopeList() {
};
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
{ key: "name", label: "Name", sortable: true },
{ key: "status", label: "Status", sortable: true },
{ key: "createdAt", label: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
];
const actions = (item: Scope) => (
@@ -49,7 +46,7 @@ export default function ScopeList() {
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
<Table data={items} columns={columns} selectable actions={actions} />
</div>
</PageWrapper>
);
@@ -1,6 +1,5 @@
import type { ScopeCreateRequest } from '../types/scopes.types';
export const validateScope = (data: Partial<ScopeCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Scope name is required';
return errors;
};
import * as Yup from 'yup';
export const scopeSchema = Yup.object().shape({
name: Yup.string().required('Scope name is required'),
});
+102 -42
View File
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useSetting } from "../hook/useSetting";
import { validateSetting } from "../validation/settings.schema";
import { settingSchema } from "../validation/settings.schema";
import { settingsService } from "../services/settings.service";
import type { SettingCreateRequest } from "../types/settings.types";
import { ArrowLeft, Save } from "lucide-react";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewSetting() {
const navigate = useNavigate();
@@ -13,57 +20,110 @@ export default function NewSetting() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useSetting();
const [form, setForm] = useState<Partial<SettingCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: settingSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as SettingCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
settingsService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateSetting(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as SettingCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Setting</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('..')}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Setting' : 'Create Setting'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update setting details' : 'Configure a new setting'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Setting Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. SMTP Host"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Setting' : 'Create Setting'}
</Button>
</div>
</form>
</PageWrapper>
);
}
+372 -41
View File
@@ -1,55 +1,386 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { User, Bell, Shield, Plug, Key, Palette, Globe, Database, MessageSquare, Webhook, Box } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useSetting } from "../hook/useSetting";
import type { Setting } from "../types/settings.types";
export default function SettingList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useSetting();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState("Integrations");
const [requireApproval, setRequireApproval] = useState(true);
const [autoPublish, setAutoPublish] = useState(false);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Setting) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
const horizontalTabs = [
{ id: "General", icon: User },
{ id: "Notifications", icon: Bell },
{ id: "Security", icon: Shield },
{ id: "Integrations", icon: Plug },
{ id: "API Keys", icon: Key },
{ id: "Appearance", icon: Palette },
];
const actions = (item: Setting) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="text-sm text-gray-500">Manage your settings</p>
<div className="mb-6">
<div className="text-sm text-gray-500 mb-1 flex items-center gap-1">
Dashboard <span className="text-gray-300"></span> <span className="text-gray-900">Settings</span>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Setting
</Button>
<h2 className="text-2xl font-bold text-gray-900">Settings</h2>
<p className="text-sm text-gray-500">Manage your platform configuration and preferences</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* Horizontal Tabs Header */}
<div className="flex items-center gap-2 border-b border-gray-200 mb-6 overflow-x-auto pb-px">
{horizontalTabs.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isActive
? "border-purple-600 text-purple-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Icon className="w-4 h-4" />
{tab.id}
</button>
);
})}
</div>
{/* Main Content Area */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
{activeTab === "General" && (
<div className="max-w-3xl">
{/* Organization Settings */}
<div className="mb-10">
<h3 className="text-lg font-bold text-gray-900 mb-4">Organization Settings</h3>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Organization Name</label>
<input
type="text"
defaultValue="Acme Corporation"
className="w-full px-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Subdomain</label>
<div className="flex items-stretch">
<input
type="text"
defaultValue="acme"
className="flex-1 px-4 py-2 text-sm border border-gray-200 rounded-l-lg border-r-0 focus:outline-none focus:ring-2 focus:ring-purple-500 z-10"
/>
<div className="px-4 py-2 bg-gray-50 border border-gray-200 rounded-r-lg text-sm text-gray-500 flex items-center">
.pim-platform.com
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Default Language</label>
<select className="w-full px-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-900 bg-white">
<option>English (US)</option>
<option>English (UK)</option>
<option>Spanish</option>
<option>French</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Timezone</label>
<select className="w-full px-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-900 bg-white">
<option>UTC (GMT+0)</option>
<option>Eastern Time (US & Canada)</option>
<option>Pacific Time (US & Canada)</option>
<option>Central European Time</option>
</select>
</div>
</div>
</div>
{/* Workflow Settings */}
<div className="mb-10">
<h3 className="text-lg font-bold text-gray-900 mb-4">Workflow Settings</h3>
<div className="space-y-6">
{/* Toggle 1 */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Require approval for all products</div>
<div className="text-xs text-gray-500 mt-0.5">All product changes must go through approval workflow</div>
</div>
<button
onClick={() => setRequireApproval(!requireApproval)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requireApproval ? 'bg-purple-600' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${requireApproval ? 'translate-x-4' : 'translate-x-1'}`} />
</button>
</div>
{/* Toggle 2 */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Auto-publish approved products</div>
<div className="text-xs text-gray-500 mt-0.5">Automatically publish products after approval</div>
</div>
<button
onClick={() => setAutoPublish(!autoPublish)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${autoPublish ? 'bg-purple-600' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${autoPublish ? 'translate-x-4' : 'translate-x-1'}`} />
</button>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="flex justify-end gap-3 pt-6 border-t border-gray-100">
<Button variant="ghost" className="text-gray-600 hover:text-gray-900 hover:bg-gray-50">
Cancel
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
Save Changes
</Button>
</div>
</div>
)}
{activeTab === "Integrations" && (
<div className="max-w-4xl">
<h3 className="text-lg font-bold text-gray-900 mb-4">Connected Integrations</h3>
<div className="flex flex-col gap-4">
{/* Shopify */}
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-emerald-100 text-emerald-600 rounded-lg flex items-center justify-center">
<Globe className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900 text-sm">Shopify</div>
<div className="text-xs text-gray-500">Connected</div>
</div>
</div>
<button className="text-sm font-medium text-gray-700 hover:text-gray-900">Configure</button>
</div>
{/* Salesforce */}
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-emerald-500 text-white rounded-lg flex items-center justify-center">
<Database className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900 text-sm">Salesforce</div>
<div className="text-xs text-gray-500">Connected</div>
</div>
</div>
<button className="text-sm font-medium text-gray-700 hover:text-gray-900">Configure</button>
</div>
{/* SAP */}
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-xl bg-gray-50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gray-200 text-gray-400 rounded-lg flex items-center justify-center">
<Box className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-500 text-sm">SAP</div>
<div className="text-xs text-gray-400">Disconnected</div>
</div>
</div>
<button className="text-sm font-medium text-gray-700 hover:text-gray-900">Connect</button>
</div>
{/* Slack */}
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-emerald-500 text-white rounded-lg flex items-center justify-center">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900 text-sm">Slack</div>
<div className="text-xs text-gray-500">Connected</div>
</div>
</div>
<button className="text-sm font-medium text-gray-700 hover:text-gray-900">Configure</button>
</div>
{/* Webhooks */}
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-purple-600 text-white rounded-lg flex items-center justify-center">
<Webhook className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900 text-sm">Webhooks</div>
<div className="text-xs text-gray-500">Active (3)</div>
</div>
</div>
<button className="text-sm font-medium text-gray-700 hover:text-gray-900">Connect</button>
</div>
</div>
{/* Footer Actions */}
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-100">
<Button variant="ghost" className="text-gray-600 hover:text-gray-900 hover:bg-gray-50">
Cancel
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
Save Changes
</Button>
</div>
</div>
)}
{activeTab === "API Keys" && (
<div className="max-w-4xl">
<h3 className="text-lg font-bold text-gray-900 mb-1">API Keys</h3>
<p className="text-sm text-gray-500 mb-6">Manage API keys for programmatic access to your PIM platform</p>
<Button className="bg-purple-600 hover:bg-purple-700 text-white mb-8">
<Key className="w-4 h-4 mr-2" />
Generate New API Key
</Button>
<h4 className="text-sm font-bold text-gray-900 mb-4">Active API Keys</h4>
<div className="flex flex-col gap-4">
{/* Production API Key */}
<div className="p-4 border border-gray-200 rounded-xl flex justify-between items-center">
<div>
<div className="font-medium text-gray-900 text-sm mb-1">Production API Key</div>
<div className="text-xs text-gray-500 mb-3">Created Jan 15, 2024 Last used 2 hours ago</div>
<div className="flex items-center gap-3">
<code className="text-xs font-mono bg-gray-50 text-gray-600 px-2 py-1 rounded border border-gray-100">pk_live_51H...XXXX</code>
<span className="text-xs font-medium text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-100">Active</span>
</div>
</div>
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 bg-red-50">
Revoke
</Button>
</div>
{/* Staging API Key */}
<div className="p-4 border border-gray-200 rounded-xl flex justify-between items-center">
<div>
<div className="font-medium text-gray-900 text-sm mb-1">Staging API Key</div>
<div className="text-xs text-gray-500 mb-3">Created Feb 3, 2024 Last used 1 day ago</div>
<div className="flex items-center gap-3">
<code className="text-xs font-mono bg-gray-50 text-gray-600 px-2 py-1 rounded border border-gray-100">pk_live_51H...XXXX</code>
<span className="text-xs font-medium text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-100">Active</span>
</div>
</div>
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50 hover:text-red-700 bg-red-50">
Revoke
</Button>
</div>
</div>
{/* Footer Actions */}
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-100">
<Button variant="ghost" className="text-gray-600 hover:text-gray-900 hover:bg-gray-50">
Cancel
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
Save Changes
</Button>
</div>
</div>
)}
{!["General", "Integrations", "API Keys"].includes(activeTab) && (
<div className="text-gray-500 text-sm py-10 text-center">
Settings for {activeTab} will appear here.
</div>
)}
{activeTab === "Appearance" && (
<div className="max-w-4xl">
<h3 className="text-lg font-bold text-gray-900 mb-6">Theme</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
{/* Light Theme */}
<button className="flex flex-col border-2 border-purple-600 rounded-xl overflow-hidden focus:outline-none ring-2 ring-purple-100 transition-all">
<div className="h-32 w-full bg-white border-b border-gray-100 flex p-4">
<div className="w-full h-full border border-gray-100 rounded shadow-sm bg-gray-50 flex flex-col gap-2 p-2">
<div className="w-1/2 h-2 bg-gray-200 rounded"></div>
<div className="w-full h-10 bg-white border border-gray-100 rounded"></div>
</div>
</div>
<div className="p-3 text-center w-full bg-white font-medium text-sm text-gray-900">
Light
</div>
</button>
{/* Dark Theme */}
<button className="flex flex-col border border-gray-200 rounded-xl overflow-hidden hover:border-gray-300 transition-all">
<div className="h-32 w-full bg-gray-900 border-b border-gray-800 flex p-4">
<div className="w-full h-full border border-gray-700 rounded shadow-sm bg-gray-800 flex flex-col gap-2 p-2">
<div className="w-1/2 h-2 bg-gray-700 rounded"></div>
<div className="w-full h-10 bg-gray-900 border border-gray-700 rounded"></div>
</div>
</div>
<div className="p-3 text-center w-full bg-white font-medium text-sm text-gray-700">
Dark
</div>
</button>
{/* Auto Theme */}
<button className="flex flex-col border border-gray-200 rounded-xl overflow-hidden hover:border-gray-300 transition-all">
<div className="h-32 w-full bg-gradient-to-r from-white to-gray-900 flex p-4">
<div className="w-full h-full border border-gray-300 rounded shadow-sm bg-gradient-to-r from-gray-50 to-gray-800 flex flex-col gap-2 p-2">
<div className="w-1/2 h-2 bg-gray-300 rounded"></div>
<div className="w-full h-10 bg-gradient-to-r from-white to-gray-900 border border-gray-400 rounded"></div>
</div>
</div>
<div className="p-3 text-center w-full bg-white font-medium text-sm text-gray-700">
Auto
</div>
</button>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-4">Density</h3>
<div className="space-y-4">
{/* Comfortable Density */}
<label className="flex items-start gap-4 p-4 border border-gray-200 rounded-xl cursor-pointer hover:border-gray-300 transition-all">
<div className="pt-0.5">
<input type="radio" name="density" defaultChecked className="w-4 h-4 text-purple-600 focus:ring-purple-500 border-gray-300" />
</div>
<div>
<div className="font-medium text-gray-900 text-sm mb-0.5">Comfortable</div>
<div className="text-xs text-gray-500">More spacing and larger elements</div>
</div>
</label>
{/* Compact Density */}
<label className="flex items-start gap-4 p-4 border border-gray-200 bg-purple-50 rounded-xl cursor-pointer transition-all">
<div className="pt-0.5">
<input type="radio" name="density" className="w-4 h-4 text-purple-600 focus:ring-purple-500 border-gray-300" />
</div>
<div>
<div className="font-medium text-gray-900 text-sm mb-0.5">Compact</div>
<div className="text-xs text-gray-500">Less spacing, more content</div>
</div>
</label>
</div>
{/* Footer Actions */}
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-100">
<Button variant="ghost" className="text-gray-600 hover:text-gray-900 hover:bg-gray-50">
Cancel
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
Save Changes
</Button>
</div>
</div>
)}
</div>
</PageWrapper>
);
@@ -1,6 +1,5 @@
import type { SettingCreateRequest } from '../types/settings.types';
export const validateSetting = (data: Partial<SettingCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Setting name is required';
return errors;
};
import * as Yup from 'yup';
export const settingSchema = Yup.object().shape({
name: Yup.string().required('Setting name is required'),
});
+155 -98
View File
@@ -1,19 +1,15 @@
import { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFormik } from 'formik';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { Button } from '../../../components/customs/Button';
import { unitService } from '../services/unit.service';
import { validateUnit } from '../validation/unit.schema';
import { unitSchema } from '../validation/unit.schema';
import type { UnitStatus } from '../types/unit.types';
import { ArrowLeft, Save } from 'lucide-react';
const STATUS_OPTIONS: { value: UnitStatus; label: string }[] = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
];
const inputClass =
'w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-shadow';
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
@@ -22,26 +18,50 @@ export default function NewUnit() {
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const [form, setForm] = useState({
name: '',
code: '',
symbol: '',
unitType: '',
conversionFactor: '',
baseUnit: '',
status: 'active' as UnitStatus,
description: '',
const formik = useFormik({
initialValues: {
name: '',
code: '',
symbol: '',
unitType: '',
conversionFactor: '',
baseUnit: '',
status: 'active' as UnitStatus,
description: '',
},
validationSchema: unitSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload = {
name: values.name,
code: values.code,
symbol: values.symbol,
unitType: values.unitType,
conversionFactor: values.conversionFactor ? parseFloat(values.conversionFactor) : undefined,
baseUnit: values.baseUnit || undefined,
status: values.status,
description: values.description,
};
try {
if (isEdit && id) {
await unitService.update(id, payload);
} else {
await unitService.create(payload);
}
navigate('/units');
} catch {
// handled inside service
} finally {
setSubmitting(false);
}
},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!id) return;
setLoading(true);
unitService.getById(id).then((u) => {
if (u) {
setForm({
formik.setValues({
name: u.name,
code: u.code,
symbol: u.symbol,
@@ -52,54 +72,10 @@ export default function NewUnit() {
description: u.description || '',
});
}
setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) setErrors((prev) => { const next = { ...prev }; delete next[field]; return next; });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = {
name: form.name,
code: form.code,
symbol: form.symbol,
unitType: form.unitType,
conversionFactor: form.conversionFactor ? parseFloat(form.conversionFactor) : undefined,
baseUnit: form.baseUnit || undefined,
status: form.status,
description: form.description,
};
const validationErrors = validateUnit(payload);
if (Object.keys(validationErrors).length) { setErrors(validationErrors); return; }
setSaving(true);
try {
if (isEdit && id) {
await unitService.update(id, payload);
} else {
await unitService.create(payload);
}
navigate('/units');
} catch {
// handled inside service
} finally {
setSaving(false);
}
};
if (loading) {
return (
<PageWrapper>
<div className="flex justify-center items-center h-64 text-sm text-gray-400">Loading...</div>
</PageWrapper>
);
}
return (
<PageWrapper>
{/* Header */}
@@ -116,65 +92,146 @@ export default function NewUnit() {
</div>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Basic Details</h2>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Unit Name *</label>
<input className={inputClass} value={form.name} onChange={set('name')} placeholder="e.g. Kilogram" />
{errors.name && <p className={errorClass}>{errors.name}</p>}
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Kilogram"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Code *</label>
<input className={inputClass} value={form.code} onChange={set('code')} placeholder="e.g. kg" />
{errors.code && <p className={errorClass}>{errors.code}</p>}
<input
name="code"
className={inputClass(formik.touched.code && Boolean(formik.errors.code))}
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. kg"
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Symbol *</label>
<input className={inputClass} value={form.symbol} onChange={set('symbol')} placeholder="e.g. kg" />
{errors.symbol && <p className={errorClass}>{errors.symbol}</p>}
<input
name="symbol"
className={inputClass(formik.touched.symbol && Boolean(formik.errors.symbol))}
value={formik.values.symbol}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. kg"
/>
{formik.touched.symbol && formik.errors.symbol && <p className={errorClass}>{formik.errors.symbol}</p>}
</div>
<div>
<label className={labelClass}>Type *</label>
<input className={inputClass} value={form.unitType} onChange={set('unitType')} placeholder="e.g. Weight" />
{errors.unitType && <p className={errorClass}>{errors.unitType}</p>}
<input
name="unitType"
className={inputClass(formik.touched.unitType && Boolean(formik.errors.unitType))}
value={formik.values.unitType}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Weight"
/>
{formik.touched.unitType && formik.errors.unitType && <p className={errorClass}>{formik.errors.unitType}</p>}
</div>
<div>
<label className={labelClass}>Status</label>
<select className={inputClass} value={form.status} onChange={set('status')}>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === 'active'}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === 'inactive'}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Conversion (Optional)</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Conversion (Optional)</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Base Unit</label>
<input className={inputClass} value={form.baseUnit} onChange={set('baseUnit')} placeholder="e.g. kg" />
<input
name="baseUnit"
className={inputClass()}
value={formik.values.baseUnit}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. kg"
/>
</div>
<div>
<label className={labelClass}>Conversion Factor</label>
<input type="number" step="0.000001" className={inputClass} value={form.conversionFactor} onChange={set('conversionFactor')} placeholder="e.g. 0.001" />
{errors.conversionFactor && <p className={errorClass}>{errors.conversionFactor}</p>}
<input
name="conversionFactor"
type="number"
step="0.000001"
className={inputClass(formik.touched.conversionFactor && Boolean(formik.errors.conversionFactor))}
value={formik.values.conversionFactor}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. 0.001"
/>
{formik.touched.conversionFactor && formik.errors.conversionFactor && (
<p className={errorClass}>{formik.errors.conversionFactor}</p>
)}
</div>
<div className="col-span-2 mt-4">
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<textarea className={inputClass} rows={3} value={form.description} onChange={set('description')} placeholder="Add notes..." />
<textarea
name="description"
className={inputClass()}
rows={3}
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Add notes..."
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button variant="outline" size="md" onClick={() => navigate('/units')}>Cancel</Button>
<Button variant="primary" size="md" icon={<Save className="w-4 h-4" />} disabled={saving}>
{saving ? 'Saving…' : isEdit ? 'Update Unit' : 'Create Unit'}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('/units')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Unit' : 'Create Unit'}
</Button>
</div>
</form>
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Plus, Download, Upload, Edit, Copy, Trash2, Radio, Box ,AlertCircle, CheckCircle2 } from "lucide-react";
import { useEffect } from "react";
import { Plus, Edit, Trash2, Radio, Box ,AlertCircle, CheckCircle2 } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { usePermissions } from "../../../hooks/usePermission";
+12 -15
View File
@@ -1,16 +1,13 @@
import type { UnitCreateRequest } from '../types/unit.types';
import * as Yup from 'yup';
export const validateUnit = (data: Partial<UnitCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Unit name is required';
if (!data.code?.trim()) errors.code = 'Unit code is required';
if (!data.symbol?.trim()) errors.symbol = 'Unit symbol is required';
if (!data.unitType?.trim()) errors.unitType = 'Unit type is required';
if (data.conversionFactor !== undefined && data.conversionFactor <= 0) {
errors.conversionFactor = 'Conversion factor must be greater than 0';
}
return errors;
};
export const unitSchema = Yup.object().shape({
name: Yup.string().required('Unit name is required'),
code: Yup.string().required('Unit code is required'),
symbol: Yup.string().required('Unit symbol is required'),
unitType: Yup.string().required('Unit type is required'),
conversionFactor: Yup.number()
.transform((value, originalValue) => originalValue === '' ? undefined : value)
.nullable()
.optional()
.min(0.00001, 'Conversion factor must be greater than 0'),
});
+102 -42
View File
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useUser } from "../hook/useUser";
import { validateUser } from "../validation/users.schema";
import { userSchema } from "../validation/users.schema";
import { usersService } from "../services/users.service";
import type { UserCreateRequest } from "../types/users.types";
import { ArrowLeft, Save } from "lucide-react";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewUser() {
const navigate = useNavigate();
@@ -13,57 +20,110 @@ export default function NewUser() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useUser();
const [form, setForm] = useState<Partial<UserCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: userSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as UserCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
usersService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateUser(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as UserCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} User</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('..')}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit User' : 'Create User'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update user details' : 'Configure a new user account'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>User Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. John Doe"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update User' : 'Create User'}
</Button>
</div>
</form>
</PageWrapper>
);
}
+352 -31
View File
@@ -1,56 +1,377 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Plus, Edit2, Key, Trash2, Shield, Users, Lock, Box, LayoutGrid, Tags, Globe, Settings, Eye } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useUser } from "../hook/useUser";
import type { User } from "../types/users.types";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { SearchBar } from "../../../components/customs/SearchBar";
// Mock Data
const MOCK_USERS = [
{ id: "1", name: "Sarah Johnson", email: "sarah.j@company.com", initials: "SJ", color: "bg-purple-600", role: "Administrator", roleColor: "bg-purple-600", status: "active", twoFa: true, lastActive: "2 min ago", joined: "Jan 15, 2023" },
{ id: "2", name: "Mike Chen", email: "mike.chen@company.com", initials: "MC", color: "bg-blue-600", role: "Product Editor", roleColor: "bg-blue-600", status: "active", twoFa: true, lastActive: "18 min ago", joined: "Mar 3, 2023" },
{ id: "3", name: "Emma Wilson", email: "e.wilson@company.com", initials: "EW", color: "bg-emerald-600", role: "Content Reviewer", roleColor: "bg-emerald-600", status: "active", twoFa: false, lastActive: "1 hour ago", joined: "Apr 20, 2023" },
{ id: "4", name: "David Lee", email: "david.lee@company.com", initials: "DL", color: "bg-blue-600", role: "Product Editor", roleColor: "bg-blue-600", status: "active", twoFa: true, lastActive: "Yesterday", joined: "Jun 11, 2023" },
{ id: "5", name: "Priya Sharma", email: "p.sharma@company.com", initials: "PS", color: "bg-gray-600", role: "Read-Only Viewer", roleColor: "bg-gray-600", status: "pending", twoFa: false, lastActive: "Never", joined: "May 12, 2024" },
];
const MOCK_ROLES = [
{ id: "admin", name: "Administrator", members: 2, desc: "Full access to all platform features, settings, and user management", color: "bg-purple-600",
permissions: [
{ module: "Products", icon: Box, perms: ["view", "create", "edit", "delete", "publish"] },
{ module: "Assets", icon: LayoutGrid, perms: ["view", "upload", "edit", "delete"] },
{ module: "Categories", icon: Tags, perms: ["view", "create", "edit", "delete"] },
{ module: "Attributes", icon: Key, perms: ["view", "create", "edit", "delete"] },
{ module: "Channels", icon: Globe, perms: ["view", "create", "edit", "delete"] },
{ module: "Users", icon: Users, perms: ["view", "create", "edit", "delete"] },
{ module: "Settings", icon: Settings, perms: ["view", "edit"] },
{ module: "Audit Logs", icon: Eye, perms: ["view"] },
]
},
{ id: "editor", name: "Product Editor", members: 5, desc: "Create and edit products, variants, and manage assets", color: "bg-blue-600",
permissions: [
{ module: "Products", icon: Box, perms: ["view", "create", "edit"] },
{ module: "Assets", icon: LayoutGrid, perms: ["view", "upload", "edit"] },
{ module: "Categories", icon: Tags, perms: ["view"] },
{ module: "Attributes", icon: Key, perms: ["view"] },
{ module: "Channels", icon: Globe, perms: ["view"] },
{ module: "Users", icon: Users, perms: [] },
{ module: "Settings", icon: Settings, perms: [] },
{ module: "Audit Logs", icon: Eye, perms: ["view"] },
]
},
{ id: "reviewer", name: "Content Reviewer", members: 3, desc: "Review and approve products for publication, read-only otherwise", color: "bg-emerald-600",
permissions: [
{ module: "Products", icon: Box, perms: ["view", "publish"] },
{ module: "Assets", icon: LayoutGrid, perms: ["view"] },
{ module: "Categories", icon: Tags, perms: ["view"] },
{ module: "Attributes", icon: Key, perms: ["view"] },
{ module: "Channels", icon: Globe, perms: ["view"] },
{ module: "Users", icon: Users, perms: [] },
{ module: "Settings", icon: Settings, perms: [] },
{ module: "Audit Logs", icon: Eye, perms: ["view"] },
]
},
{ id: "viewer", name: "Read-Only Viewer", members: 8, desc: "View-only access to products, assets, and categories", color: "bg-gray-600",
permissions: [
{ module: "Products", icon: Box, perms: ["view"] },
{ module: "Assets", icon: LayoutGrid, perms: ["view"] },
{ module: "Categories", icon: Tags, perms: ["view"] },
{ module: "Attributes", icon: Key, perms: ["view"] },
{ module: "Channels", icon: Globe, perms: ["view"] },
{ module: "Users", icon: Users, perms: [] },
{ module: "Settings", icon: Settings, perms: [] },
{ module: "Audit Logs", icon: Eye, perms: [] },
]
},
];
export default function UserList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useUser();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState("Roles & Permissions");
const [searchQuery, setSearchQuery] = useState("");
const [roles, setRoles] = useState(MOCK_ROLES);
const [selectedRoleId, setSelectedRoleId] = useState(MOCK_ROLES[0].id);
const [isEditingRole, setIsEditingRole] = useState(false);
const selectedRole = roles.find(r => r.id === selectedRoleId) || roles[0];
const [editedRole, setEditedRole] = useState(selectedRole);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleTogglePermission = (moduleName: string, perm: string) => {
setEditedRole(prev => {
const newPermissions = prev.permissions.map(p => {
if (p.module === moduleName) {
const hasPerm = p.perms.includes(perm);
return {
...p,
perms: hasPerm ? p.perms.filter(x => x !== perm) : [...p.perms, perm]
};
}
return p;
});
return { ...prev, permissions: newPermissions };
});
};
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: User) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
const handleSaveRole = () => {
setRoles(prev => prev.map(r => r.id === editedRole.id ? editedRole : r));
setIsEditingRole(false);
};
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
{
key: "member",
label: "MEMBER",
render: (_: any, row: any) => (
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-medium ${row.color}`}>
{row.initials}
</div>
<div>
<div className="font-medium text-gray-900">{row.name}</div>
<div className="text-xs text-gray-500">{row.email}</div>
</div>
</div>
)
},
{
key: "role",
label: "ROLE",
render: (_: any, row: any) => (
<span className={`inline-flex px-2 py-1 text-xs font-medium text-white rounded ${row.roleColor}`}>
{row.role}
</span>
)
},
{
key: "status",
label: "STATUS",
render: (val: any) => <StatusBadge status={val} />
},
{
key: "twoFa",
label: "2FA",
render: (val: boolean) => (
<div className={`flex items-center gap-1.5 text-sm ${val ? 'text-emerald-600' : 'text-gray-400'}`}>
<Lock className="w-4 h-4" />
<span>{val ? 'Enabled' : 'Disabled'}</span>
</div>
)
},
{
key: "lastActive",
label: "LAST ACTIVE",
render: (val: string) => <span className="text-sm text-gray-600">{val}</span>
},
{
key: "joined",
label: "JOINED",
render: (val: string) => <span className="text-sm text-gray-600">{val}</span>
}
];
const actions = (item: User) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
const actions = () => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded transition-colors"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded transition-colors"><Key className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded transition-colors"><Trash2 className="w-4 h-4" /></button>
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<p className="text-sm text-gray-500">Manage your users</p>
<h1 className="text-2xl font-bold text-gray-900">Users & Roles</h1>
<p className="text-sm text-gray-500 mt-1">Manage team members, permissions, and access controls</p>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create User
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
<Plus className="w-4 h-4 mr-2" />
Invite Member
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white p-5 rounded-xl border border-gray-100 shadow-sm flex flex-col justify-center">
<h3 className="text-3xl font-bold text-gray-900">6</h3>
<p className="text-sm text-gray-500 font-medium mt-1">Total Members</p>
<p className="text-xs text-gray-400">4 active</p>
</div>
<div className="bg-purple-50/50 p-5 rounded-xl border border-purple-100/50 flex flex-col justify-center">
<h3 className="text-3xl font-bold text-purple-600">4</h3>
<p className="text-sm text-purple-700 font-medium mt-1">Roles Defined</p>
<p className="text-xs text-purple-400">permission groups</p>
</div>
<div className="bg-emerald-50/50 p-5 rounded-xl border border-emerald-100/50 flex flex-col justify-center">
<h3 className="text-3xl font-bold text-emerald-600">3</h3>
<p className="text-sm text-emerald-700 font-medium mt-1">2FA Enabled</p>
<p className="text-xs text-emerald-400">50% adoption</p>
</div>
<div className="bg-amber-50/50 p-5 rounded-xl border border-amber-100/50 flex flex-col justify-center">
<h3 className="text-3xl font-bold text-amber-600">1</h3>
<p className="text-sm text-amber-700 font-medium mt-1">Pending Invites</p>
<p className="text-xs text-amber-400">awaiting signup</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => { setActiveTab("Team Members"); setIsEditingRole(false); }}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeTab === "Team Members" ? "border-purple-200 text-purple-700 bg-white shadow-sm" : "border-transparent text-gray-500 hover:text-gray-900"
}`}
>
<Users className="w-4 h-4" />
Team Members
</button>
<button
onClick={() => setActiveTab("Roles & Permissions")}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
activeTab === "Roles & Permissions" ? "border-purple-200 text-purple-700 bg-white shadow-sm" : "border-transparent text-gray-500 hover:text-gray-900"
}`}
>
<Shield className="w-4 h-4" />
Roles & Permissions
</button>
</div>
{/* Main Content Area */}
{activeTab === "Team Members" && (
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<div className="w-64">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search members..." />
</div>
<select className="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white w-40">
<option>All Roles</option>
<option>Administrator</option>
<option>Product Editor</option>
<option>Content Reviewer</option>
</select>
</div>
<div className="text-sm text-gray-500">
6 members
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-2">
<Table
columns={columns}
data={MOCK_USERS}
actions={actions}
/>
</div>
</div>
)}
{activeTab === "Roles & Permissions" && (
<div className="flex flex-col lg:flex-row gap-6">
{/* Left Sidebar - Roles List */}
<div className="w-full lg:w-1/3 flex flex-col gap-4">
{MOCK_ROLES.map((role) => (
<div
key={role.id}
onClick={() => { setSelectedRoleId(role.id); setIsEditingRole(false); setEditedRole(role); }}
className={`p-4 rounded-xl border cursor-pointer transition-all ${
selectedRoleId === role.id
? "border-purple-300 shadow-sm bg-white ring-1 ring-purple-100"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${role.color}`}></div>
<span className="font-semibold text-gray-900">{role.name}</span>
</div>
<span className="text-xs text-gray-500">{role.members} members</span>
</div>
<p className="text-sm text-gray-500 line-clamp-2">{role.desc}</p>
</div>
))}
<button className="p-4 rounded-xl border border-dashed border-gray-300 text-gray-500 hover:text-gray-900 hover:border-gray-400 hover:bg-gray-50 transition-colors flex items-center justify-center gap-2 text-sm font-medium">
<Plus className="w-4 h-4" />
Create New Role
</button>
</div>
{/* Right Main Area - Role Details */}
<div className="w-full lg:w-2/3 bg-white rounded-xl border border-gray-200 shadow-sm">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<div className={`w-3.5 h-3.5 rounded-full ${selectedRole.color}`}></div>
<h2 className="text-xl font-bold text-gray-900">{selectedRole.name}</h2>
<span className="text-sm text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{selectedRole.members} members</span>
</div>
<p className="text-sm text-gray-600">{selectedRole.desc}</p>
</div>
{!isEditingRole ? (
<Button variant="outline" className="text-gray-700" onClick={() => { setIsEditingRole(true); setEditedRole(selectedRole); }}>
<Edit2 className="w-4 h-4 mr-2" />
Edit Role
</Button>
) : (
<div className="flex gap-2">
<Button variant="outline" className="text-gray-600 hover:bg-gray-50" onClick={() => { setIsEditingRole(false); setEditedRole(selectedRole); }}>
Cancel
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white" onClick={handleSaveRole}>
Save Changes
</Button>
</div>
)}
</div>
{/* Permissions List */}
<div className="p-6 flex flex-col gap-6">
{selectedRole.permissions && selectedRole.permissions.length > 0 ? (
selectedRole.permissions.map((permGroup, idx) => {
const Icon = permGroup.icon;
const availablePerms = ["view", "create", "upload", "edit", "delete", "publish"];
return (
<div key={idx} className="flex items-center">
<div className="w-48 flex items-center gap-3 text-sm font-medium text-gray-700">
<Icon className="w-4 h-4 text-gray-400" />
{permGroup.module}
</div>
<div className="flex flex-wrap gap-2">
{isEditingRole ? (
availablePerms.map((p, i) => {
const hasPerm = editedRole.permissions.find(x => x.module === permGroup.module)?.perms.includes(p);
return (
<label key={i} className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded border cursor-pointer transition-colors ${hasPerm ? 'bg-purple-50 text-purple-700 border-purple-200' : 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'}`}>
<input
type="checkbox"
checked={hasPerm || false}
onChange={() => handleTogglePermission(permGroup.module, p)}
className="w-3 h-3 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
/>
<span className="capitalize">{p}</span>
</label>
);
})
) : (
permGroup.perms.length === 0 ? (
<span className="px-2.5 py-1 text-xs font-medium rounded border bg-gray-50 text-gray-400 border-gray-200">
No access
</span>
) : (
permGroup.perms.map((p, i) => {
let badgeColor = "bg-emerald-50 text-emerald-700 border-emerald-100";
if (p === 'delete') badgeColor = "bg-red-50 text-red-700 border-red-100";
if (p === 'publish') badgeColor = "bg-purple-50 text-purple-700 border-purple-100";
if (p === 'upload') badgeColor = "bg-emerald-50 text-emerald-700 border-emerald-100";
return (
<span key={i} className={`px-2.5 py-1 text-xs font-medium rounded border capitalize ${badgeColor}`}>
{p}
</span>
);
})
)
)}
</div>
</div>
);
})
) : (
<div className="text-sm text-gray-500 py-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-200">
Select a role with defined permissions, or click Edit to assign permissions.
</div>
)}
</div>
</div>
</div>
)}
</PageWrapper>
);
}
@@ -1,6 +1,5 @@
import type { UserCreateRequest } from '../types/users.types';
export const validateUser = (data: Partial<UserCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'User name is required';
return errors;
};
import * as Yup from 'yup';
export const userSchema = Yup.object().shape({
name: Yup.string().required('User name is required'),
});
+153 -106
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFormik } from 'formik';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
import { Button } from '../../../components/customs/Button';
import { variantService } from '../services/variant.service';
import { validateVariant } from '../validation/variant.schema';
import { variantSchema } from '../validation/variant.schema';
import type { VariantStatus } from '../types/variant.types';
import { ArrowLeft, Save } from 'lucide-react';
@@ -14,15 +15,8 @@ const PRODUCT_OPTIONS = [
{ id: '4', name: 'Smart LED Desk Lamp' },
];
const STATUS_OPTIONS: { value: VariantStatus; label: string }[] = [
{ value: 'active', label: 'Active' },
{ value: 'draft', label: 'Draft' },
{ value: 'disabled', label: 'Disabled' },
{ value: 'published', label: 'Published' },
];
const inputClass =
'w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-shadow';
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
@@ -31,27 +25,54 @@ export default function NewVariant() {
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const [form, setForm] = useState({
sku: '',
name: '',
parentProductId: '',
parentProductName: '',
status: 'draft' as VariantStatus,
price: '',
stock: '',
colorAttr: '',
sizeAttr: '',
const formik = useFormik({
initialValues: {
sku: '',
name: '',
parentProductId: '',
parentProductName: '',
status: 'draft' as VariantStatus,
price: '',
stock: '',
colorAttr: '',
sizeAttr: '',
},
validationSchema: variantSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload = {
sku: values.sku,
name: values.name,
parentProductId: values.parentProductId,
parentProductName: values.parentProductName,
status: values.status,
price: parseFloat(values.price) || 0,
stock: parseInt(values.stock) || 0,
attributes: {
...(values.colorAttr ? { color: values.colorAttr } : {}),
...(values.sizeAttr ? { size: values.sizeAttr } : {}),
},
};
try {
if (isEdit && id) {
await variantService.update(id, payload);
} else {
await variantService.create(payload);
}
navigate('/variants');
} catch {
// handled inside service
} finally {
setSubmitting(false);
}
},
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!id) return;
setLoading(true);
variantService.getById(id).then((v) => {
if (v) {
setForm({
formik.setValues({
sku: v.sku,
name: v.name,
parentProductId: v.parentProductId,
@@ -63,62 +84,16 @@ export default function NewVariant() {
sizeAttr: v.attributes.size || '',
});
}
setLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) setErrors((prev) => { const next = { ...prev }; delete next[field]; return next; });
};
const handleProductChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const product = PRODUCT_OPTIONS.find((p) => p.id === e.target.value);
setForm((prev) => ({ ...prev, parentProductId: e.target.value, parentProductName: product?.name || '' }));
formik.setFieldValue('parentProductId', e.target.value);
formik.setFieldValue('parentProductName', product?.name || '');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload = {
sku: form.sku,
name: form.name,
parentProductId: form.parentProductId,
parentProductName: form.parentProductName,
status: form.status,
price: parseFloat(form.price) || 0,
stock: parseInt(form.stock) || 0,
attributes: {
...(form.colorAttr ? { color: form.colorAttr } : {}),
...(form.sizeAttr ? { size: form.sizeAttr } : {}),
},
};
const validationErrors = validateVariant(payload);
if (Object.keys(validationErrors).length) { setErrors(validationErrors); return; }
setSaving(true);
try {
if (isEdit && id) {
await variantService.update(id, payload);
} else {
await variantService.create(payload);
}
navigate('/variants');
} catch {
// handled inside service
} finally {
setSaving(false);
}
};
if (loading) {
return (
<PageWrapper>
<div className="flex justify-center items-center h-64 text-sm text-gray-400">Loading...</div>
</PageWrapper>
);
}
return (
<PageWrapper>
{/* Header */}
@@ -135,79 +110,151 @@ export default function NewVariant() {
</div>
</div>
<form onSubmit={handleSubmit} className="max-w-2xl space-y-6">
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
{/* Basic Info */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Basic Information</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>SKU *</label>
<input className={inputClass} value={form.sku} onChange={set('sku')} placeholder="e.g. WH-PRO-BLK-M" />
{errors.sku && <p className={errorClass}>{errors.sku}</p>}
<input
name="sku"
className={inputClass(formik.touched.sku && Boolean(formik.errors.sku))}
value={formik.values.sku}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. WH-PRO-BLK-M"
/>
{formik.touched.sku && formik.errors.sku && <p className={errorClass}>{formik.errors.sku}</p>}
</div>
<div>
<label className={labelClass}>Variant Name *</label>
<input className={inputClass} value={form.name} onChange={set('name')} placeholder="e.g. Black / Medium" />
{errors.name && <p className={errorClass}>{errors.name}</p>}
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Black / Medium"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="col-span-2">
<div className="md:col-span-2">
<label className={labelClass}>Parent Product *</label>
<select className={inputClass} value={form.parentProductId} onChange={handleProductChange}>
<select
name="parentProductId"
className={inputClass(formik.touched.parentProductId && Boolean(formik.errors.parentProductId))}
value={formik.values.parentProductId}
onChange={handleProductChange}
onBlur={formik.handleBlur}
>
<option value="">Select product</option>
{PRODUCT_OPTIONS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{errors.parentProductId && <p className={errorClass}>{errors.parentProductId}</p>}
{formik.touched.parentProductId && formik.errors.parentProductId && (
<p className={errorClass}>{formik.errors.parentProductId}</p>
)}
</div>
<div>
<label className={labelClass}>Status</label>
<select className={inputClass} value={form.status} onChange={set('status')}>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
<div className="flex gap-4 mt-2">
{['draft', 'active', 'disabled', 'published'].map((s) => (
<label key={s} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value={s}
checked={formik.values.status === s}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium capitalize">{s}</span>
</label>
))}
</select>
</div>
</div>
</div>
</div>
{/* Pricing & Stock */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Pricing &amp; Stock</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Pricing &amp; Stock</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Price ($) *</label>
<input type="number" min="0" step="0.01" className={inputClass} value={form.price} onChange={set('price')} placeholder="0.00" />
{errors.price && <p className={errorClass}>{errors.price}</p>}
<input
name="price"
type="number"
min="0"
step="0.01"
className={inputClass(formik.touched.price && Boolean(formik.errors.price))}
value={formik.values.price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="0.00"
/>
{formik.touched.price && formik.errors.price && <p className={errorClass}>{formik.errors.price}</p>}
</div>
<div>
<label className={labelClass}>Stock Quantity</label>
<input type="number" min="0" className={inputClass} value={form.stock} onChange={set('stock')} placeholder="0" />
{errors.stock && <p className={errorClass}>{errors.stock}</p>}
<input
name="stock"
type="number"
min="0"
className={inputClass(formik.touched.stock && Boolean(formik.errors.stock))}
value={formik.values.stock}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="0"
/>
{formik.touched.stock && formik.errors.stock && <p className={errorClass}>{formik.errors.stock}</p>}
</div>
</div>
</div>
{/* Attributes */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-4">Attributes</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Attributes</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className={labelClass}>Color</label>
<input className={inputClass} value={form.colorAttr} onChange={set('colorAttr')} placeholder="e.g. Black" />
<input
name="colorAttr"
className={inputClass()}
value={formik.values.colorAttr}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Black"
/>
</div>
<div>
<label className={labelClass}>Size</label>
<input className={inputClass} value={form.sizeAttr} onChange={set('sizeAttr')} placeholder="e.g. M or 500ml" />
<input
name="sizeAttr"
className={inputClass()}
value={formik.values.sizeAttr}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. M or 500ml"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<Button variant="outline" size="md" onClick={() => navigate('/variants')}>Cancel</Button>
<Button variant="primary" size="md" icon={<Save className="w-4 h-4" />} disabled={saving}>
{saving ? 'Saving…' : isEdit ? 'Update Variant' : 'Create Variant'}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('/variants')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Variant' : 'Create Variant'}
</Button>
</div>
</form>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Grid3x3, CheckCircle2, Clock, XCircle, Globe, Upload, Download, RefreshCw, Plus, Edit, Copy, Trash2 } from "lucide-react";
import { Grid3x3, CheckCircle2, Clock, XCircle, Globe, Upload, Download, Plus, Edit, Trash2 } from "lucide-react";
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
import { Button } from "../../../components/customs/Button";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -1,27 +1,16 @@
import type { VariantCreateRequest } from '../types/variant.types';
import * as Yup from 'yup';
export const validateVariant = (data: Partial<VariantCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.sku?.trim()) {
errors.sku = 'SKU is required';
}
if (!data.name?.trim()) {
errors.name = 'Variant name is required';
}
if (!data.parentProductId?.trim()) {
errors.parentProductId = 'Parent product is required';
}
if (data.price === undefined || data.price < 0) {
errors.price = 'Price must be a non-negative number';
}
if (data.stock !== undefined && data.stock < 0) {
errors.stock = 'Stock cannot be negative';
}
return errors;
};
export const variantSchema = Yup.object().shape({
sku: Yup.string().required('SKU is required'),
name: Yup.string().required('Variant name is required'),
parentProductId: Yup.string().required('Parent product is required'),
price: Yup.number()
.transform((value, originalValue) => originalValue === '' ? undefined : value)
.required('Price is required')
.min(0, 'Price must be a non-negative number'),
stock: Yup.number()
.transform((value, originalValue) => originalValue === '' ? undefined : value)
.nullable()
.optional()
.min(0, 'Stock cannot be negative'),
});
+102 -42
View File
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useWorkflow } from "../hook/useWorkflow";
import { validateWorkflow } from "../validation/workflow.schema";
import { workflowSchema } from "../validation/workflow.schema";
import { workflowService } from "../services/workflow.service";
import type { WorkflowCreateRequest } from "../types/workflow.types";
import { ArrowLeft, Save } from "lucide-react";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
export default function NewWorkflow() {
const navigate = useNavigate();
@@ -13,57 +20,110 @@ export default function NewWorkflow() {
const isEdit = Boolean(id);
const { createItem, updateItem } = useWorkflow();
const [form, setForm] = useState<Partial<WorkflowCreateRequest>>({ name: "", status: "active" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active" as "active" | "inactive",
},
validationSchema: workflowSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as WorkflowCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (isEdit && id) {
workflowService.getById(id).then(item => {
if (item) setForm({ name: item.name, status: item.status });
if (item) formik.setValues({ name: item.name, status: item.status });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateWorkflow(form);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSaving(true);
try {
if (isEdit && id) {
await updateItem(id, form as any);
} else {
await createItem(form as WorkflowCreateRequest);
}
navigate("..");
} catch {
// toast handled
} finally {
setSaving(false);
}
};
return (
<PageWrapper>
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Workflow</h1>
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving}>Save</Button>
</div>
</form>
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => navigate('..')}
className="p-2 rounded-lg hover:bg-gray-200 transition-colors text-gray-500 hover:text-gray-900"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{isEdit ? 'Edit Workflow' : 'Create Workflow'}</h1>
<p className="text-sm text-gray-500 mt-1">{isEdit ? 'Update workflow details' : 'Configure a new workflow'}</p>
</div>
</div>
<form onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Workflow Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Standard Review Workflow"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
/>
<span className="text-sm text-gray-700 font-medium">Inactive</span>
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-gray-200">
<Button variant="outline" size="md" type="button" onClick={() => navigate('..')} disabled={formik.isSubmitting}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
{isEdit ? 'Update Workflow' : 'Create Workflow'}
</Button>
</div>
</form>
</PageWrapper>
);
}
+143 -35
View File
@@ -1,55 +1,163 @@
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Plus, Edit2, Eye, MoreHorizontal, Upload, Download, Activity, CheckCircle, Clock, Layers } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Table } from "../../../components/customs/Table";
import { useWorkflow } from "../hook/useWorkflow";
import type { Workflow } from "../types/workflow.types";
import { KPI, KPIGrid } from "../../../components/customs/KPI";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { SearchBar } from "../../../components/customs/SearchBar";
// Mock Data
const MOCK_WORKFLOWS = [
{ id: "1", name: "Electronics Approval Workflow", desc: "Multi-stage approval process for electronics product...", code: "electronics_approval", stages: 5, families: 8, products: "4,240", pending: 23, status: "active", lastModified: "2025-03-15", author: "Sarah Chen", isDefault: false },
{ id: "2", name: "Fashion Approval Workflow", desc: "Streamlined approval for fashion items with visual...", code: "fashion_approval", stages: 4, families: 12, products: "8,940", pending: 41, status: "active", lastModified: "2025-03-12", author: "Michael Torres", isDefault: true },
{ id: "3", name: "Marketplace Publishing Workflow", desc: "Optimised for marketplace channels with SEO review...", code: "marketplace_publish", stages: 6, families: 5, products: "2,140", pending: 12, status: "active", lastModified: "2025-03-10", author: "Emma Wilson", isDefault: false },
{ id: "4", name: "Compliance Review Workflow", desc: "Legal and compliance checks for restricted categories", code: "compliance_review", stages: 3, families: 2, products: "840", pending: 5, status: "active", lastModified: "2025-02-28", author: "David Park", isDefault: false },
{ id: "5", name: "Seasonal Promotion Approval", desc: "Fast-track approval for seasonal marketing campaigns", code: "seasonal_promo", stages: 2, families: 15, products: "12,500", pending: 8, status: "draft", lastModified: "2025-02-15", author: "Lisa Wong", isDefault: false },
];
export default function WorkflowList() {
const navigate = useNavigate();
const { items, fetchItems, deleteItem } = useWorkflow();
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
fetchItems();
}, [fetchItems]);
const handleSelect = (ids: Set<string>) => setSelected(ids);
const handleDelete = async (item: Workflow) => {
if (confirm('Delete this item?')) {
await deleteItem(item.id);
}
};
const [activeTab, setActiveTab] = useState("All");
const [searchQuery, setSearchQuery] = useState("");
const columns = [
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", sortable: true },
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
{
key: "name",
label: "Workflow Name",
sortable: true,
render: (_: any, row: any) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-purple-50 flex items-center justify-center shrink-0">
<Activity className="w-4 h-4 text-purple-600" />
</div>
<div>
<div className="font-medium text-gray-900 flex items-center gap-2">
{row.name}
{row.isDefault && (
<span className="text-[10px] font-semibold uppercase tracking-wider bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">Default</span>
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">{row.desc}</div>
</div>
</div>
),
},
{
key: "code",
label: "Code",
render: (val: string) => <span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{val}</span>,
},
{
key: "stages",
label: "Stages",
render: (val: number) => (
<div className="flex items-center gap-1 text-gray-700 font-medium">
<Layers className="w-3.5 h-3.5 text-gray-400" />
{val}
</div>
),
},
{ key: "families", label: "Assigned Families" },
{ key: "products", label: "Active Products" },
{
key: "pending",
label: "Pending",
render: (val: number) => (
<div className="inline-flex items-center justify-center gap-1.5 px-2 py-1 rounded bg-amber-50 text-amber-700 font-medium text-xs border border-amber-100/50">
<Clock className="w-3 h-3" />
{val}
</div>
),
},
{
key: "status",
label: "Status",
render: (val: any) => <StatusBadge status={val} />,
},
{
key: "lastModified",
label: "Last Modified",
render: (_: any, row: any) => (
<div>
<div className="text-gray-900 font-medium text-sm">{row.lastModified}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.author}</div>
</div>
),
},
];
const actions = (item: Workflow) => (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
const actions = () => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><MoreHorizontal className="w-4 h-4" /></button>
</div>
);
return (
<PageWrapper>
<div className="flex justify-between items-center mb-6">
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Workflows</h1>
<p className="text-sm text-gray-500">Manage your workflow</p>
<div className="text-sm text-gray-500 mb-1">Masters / Workflow Management</div>
<h1 className="text-2xl font-bold text-gray-900">Workflow Management</h1>
<p className="text-sm text-gray-500">Configure governance and approval workflows for product lifecycle management</p>
</div>
<div className="flex gap-3">
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
<Upload className="w-4 h-4 mr-2" />
Import Workflow
</Button>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
<Download className="w-4 h-4 mr-2" />
Export Config
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
<Plus className="w-4 h-4 mr-2" />
Create Workflow
</Button>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
Create Workflow
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
{/* Stats Cards */}
<KPIGrid className="mb-6">
<KPI value="5" label="Total Workflows" icon={Activity} variant="purple" trendValue="+2 this month" trendDirection="up" />
<KPI value="5" label="Active Workflows" icon={CheckCircle} variant="green" dots={[{ color: "green", label: "All healthy" }]} />
<KPI value="34" label="Product Families Assigned" icon={Layers} variant="blue" trendValue="Across 5 workflows" />
<KPI value="89" label="Pending Approvals" icon={Clock} variant="amber" trendValue="Awaiting action" />
</KPIGrid>
{/* Main Content Area */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col gap-4 p-4">
{/* Toolbar */}
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<div className="w-64">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search workflows..." />
</div>
<div className="flex bg-gray-50 p-1 rounded-lg">
{['All', 'Active', 'Draft', 'Inactive', 'Archived'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${activeTab === tab ? 'bg-purple-600 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}`}
>
{tab}
</button>
))}
</div>
</div>
<div className="text-sm text-gray-500">
6 workflows
</div>
</div>
{/* Table */}
<Table
columns={columns}
data={MOCK_WORKFLOWS}
actions={actions}
/>
</div>
</PageWrapper>
);
@@ -1,6 +1,5 @@
import type { WorkflowCreateRequest } from '../types/workflow.types';
export const validateWorkflow = (data: Partial<WorkflowCreateRequest>): Record<string, string> => {
const errors: Record<string, string> = {};
if (!data.name?.trim()) errors.name = 'Workflow name is required';
return errors;
};
import * as Yup from 'yup';
export const workflowSchema = Yup.object().shape({
name: Yup.string().required('Workflow name is required'),
});