frontend screens implementataions
This commit is contained in:
Generated
+130
-1
@@ -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
@@ -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
@@ -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}</>;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 () => ({})
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface InfoCardProps {
|
||||
icon: ReactNode;
|
||||
trend?: string;
|
||||
trendDirection?: "up" | "down" | "neutral";
|
||||
subtitle?: string;
|
||||
subtitle?: ReactNode;
|
||||
variant?: InfoCardVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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 & 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'),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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 & 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 & 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,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'),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user