Initial commit
This commit is contained in:
commit
22001cb54c
24
.env.example
Normal file
24
.env.example
Normal file
@ -0,0 +1,24 @@
|
||||
# PMC Frontend Environment Variables
|
||||
|
||||
# Application
|
||||
VITE_APP_NAME=Platform Marketing Content
|
||||
VITE_APP_VERSION=1.0.0
|
||||
VITE_APP_ENV=development
|
||||
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:3111
|
||||
VITE_API_PREFIX=api/v1
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# WebSocket
|
||||
VITE_WS_URL=ws://localhost:3111
|
||||
|
||||
# Feature Flags
|
||||
VITE_ENABLE_DEBUG=true
|
||||
VITE_ENABLE_ANALYTICS=false
|
||||
|
||||
# Storage (MinIO/S3 public access)
|
||||
VITE_STORAGE_URL=http://localhost:9000
|
||||
|
||||
# ComfyUI
|
||||
VITE_COMFYUI_URL=http://localhost:8188
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
# =============================================================================
|
||||
# PMC Frontend - Dockerfile
|
||||
# =============================================================================
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD wget --spider -q http://localhost:80 || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PMC - Platform Marketing Content</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
nginx.conf
Normal file
25
nginx.conf
Normal file
@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /health {
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
6087
package-lock.json
generated
Normal file
6087
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
package.json
Normal file
61
package.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@pmc/frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tanstack/react-query-devtools": "^5.17.9",
|
||||
"axios": "^1.6.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"lucide-react": "^0.309.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-router-dom": "^6.21.2",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.8",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
88
src/App.tsx
Normal file
88
src/App.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
|
||||
// Layouts
|
||||
import MainLayout from '@/components/common/Layout/MainLayout';
|
||||
import AuthLayout from '@/components/common/Layout/AuthLayout';
|
||||
|
||||
// Auth Pages
|
||||
import LoginPage from '@/pages/auth/LoginPage';
|
||||
|
||||
// Protected Pages
|
||||
import DashboardPage from '@/pages/dashboard/DashboardPage';
|
||||
|
||||
// CRM Pages
|
||||
import ClientsPage from '@/pages/crm/ClientsPage';
|
||||
import ClientFormPage from '@/pages/crm/ClientFormPage';
|
||||
import BrandsPage from '@/pages/crm/BrandsPage';
|
||||
import BrandFormPage from '@/pages/crm/BrandFormPage';
|
||||
import ProductsPage from '@/pages/crm/ProductsPage';
|
||||
import ProductFormPage from '@/pages/crm/ProductFormPage';
|
||||
|
||||
// Assets Pages
|
||||
import AssetsPage from '@/pages/assets/AssetsPage';
|
||||
|
||||
// Projects Pages
|
||||
import ProjectsPage from '@/pages/projects/ProjectsPage';
|
||||
|
||||
// Protected Route Component
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
{/* Auth Routes */}
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<LoginPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
|
||||
{/* CRM Routes */}
|
||||
<Route path="/crm/clients" element={<ClientsPage />} />
|
||||
<Route path="/crm/clients/new" element={<ClientFormPage />} />
|
||||
<Route path="/crm/clients/:id/edit" element={<ClientFormPage />} />
|
||||
<Route path="/crm/brands" element={<BrandsPage />} />
|
||||
<Route path="/crm/brands/new" element={<BrandFormPage />} />
|
||||
<Route path="/crm/brands/:id/edit" element={<BrandFormPage />} />
|
||||
<Route path="/crm/products" element={<ProductsPage />} />
|
||||
<Route path="/crm/products/new" element={<ProductFormPage />} />
|
||||
<Route path="/crm/products/:id/edit" element={<ProductFormPage />} />
|
||||
|
||||
{/* Assets Routes */}
|
||||
<Route path="/assets" element={<AssetsPage />} />
|
||||
|
||||
{/* Projects Routes */}
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
11
src/components/common/Layout/AuthLayout.tsx
Normal file
11
src/components/common/Layout/AuthLayout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted/50">
|
||||
<div className="w-full max-w-md p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/common/Layout/Header.tsx
Normal file
33
src/components/common/Layout/Header.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, User } from 'lucide-react';
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b bg-background px-6">
|
||||
{/* Left side - can add breadcrumbs or search */}
|
||||
<div></div>
|
||||
|
||||
{/* Right side - user menu */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-medium">
|
||||
{user?.first_name || user?.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={logout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
17
src/components/common/Layout/MainLayout.tsx
Normal file
17
src/components/common/Layout/MainLayout.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import Header from './Header';
|
||||
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="lg:pl-64">
|
||||
<Header />
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/common/Layout/Sidebar.tsx
Normal file
73
src/components/common/Layout/Sidebar.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FolderKanban,
|
||||
Wand2,
|
||||
Image,
|
||||
Zap,
|
||||
BarChart3,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'CRM', href: '/crm', icon: Users },
|
||||
{ name: 'Proyectos', href: '/projects', icon: FolderKanban },
|
||||
{ name: 'Generacion', href: '/generation', icon: Wand2 },
|
||||
{ name: 'Assets', href: '/assets', icon: Image },
|
||||
{ name: 'Automatizacion', href: '/automation', icon: Zap },
|
||||
{ name: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ name: 'Configuracion', href: '/admin', icon: Settings },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-50 hidden w-64 flex-col border-r bg-card lg:flex">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center border-b px-6">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-lg">P</span>
|
||||
</div>
|
||||
<span className="font-semibold text-lg">PMC</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto p-4">
|
||||
<ul className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.href);
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t p-4">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
PMC v0.1.0
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
55
src/components/ui/button.tsx
Normal file
55
src/components/ui/button.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
176
src/components/ui/form.tsx
Normal file
176
src/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
125
src/components/ui/toast.tsx
Normal file
125
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import * as React from 'react';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
185
src/hooks/use-toast.ts
Normal file
185
src/hooks/use-toast.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import * as React from 'react';
|
||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
110
src/hooks/useAssets.ts
Normal file
110
src/hooks/useAssets.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { assetsApi, Asset } from '@/services/api/assets.api';
|
||||
import { PaginationParams } from '@/services/api/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useAssets(
|
||||
params?: PaginationParams & { brandId?: string; type?: string; search?: string }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['assets', params],
|
||||
queryFn: () => assetsApi.getAll(params).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAsset(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['assets', id],
|
||||
queryFn: () => assetsApi.getById(id).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssetsByBrand(brandId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['assets', 'brand', brandId],
|
||||
queryFn: () => assetsApi.getByBrand(brandId).then((res) => res.data),
|
||||
enabled: !!brandId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAsset() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<Asset>) => assetsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
toast({ title: 'Asset creado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al crear asset',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAsset() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Asset> }) =>
|
||||
assetsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
toast({ title: 'Asset actualizado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar asset',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAsset() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => assetsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
toast({ title: 'Asset eliminado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar asset',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkDeleteAssets() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => assetsApi.bulkDelete(ids),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assets'] });
|
||||
toast({ title: 'Assets eliminados exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar assets',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
87
src/hooks/useBrands.ts
Normal file
87
src/hooks/useBrands.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandsApi, Brand, PaginationParams } from '@/services/api/crm.api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useBrands(params?: PaginationParams & { clientId?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['brands', params],
|
||||
queryFn: () => brandsApi.getAll(params).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBrand(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['brands', id],
|
||||
queryFn: () => brandsApi.getById(id).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBrandsByClient(clientId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['brands', 'client', clientId],
|
||||
queryFn: () => brandsApi.getByClient(clientId).then((res) => res.data),
|
||||
enabled: !!clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBrand() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<Brand>) => brandsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] });
|
||||
toast({ title: 'Marca creada exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al crear marca',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBrand() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Brand> }) =>
|
||||
brandsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] });
|
||||
toast({ title: 'Marca actualizada exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar marca',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBrand() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => brandsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] });
|
||||
toast({ title: 'Marca eliminada exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar marca',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
79
src/hooks/useClients.ts
Normal file
79
src/hooks/useClients.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { clientsApi, Client, PaginationParams } from '@/services/api/crm.api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useClients(params?: PaginationParams) {
|
||||
return useQuery({
|
||||
queryKey: ['clients', params],
|
||||
queryFn: () => clientsApi.getAll(params).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClient(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['clients', id],
|
||||
queryFn: () => clientsApi.getById(id).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClient() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<Client>) => clientsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
toast({ title: 'Cliente creado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al crear cliente',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateClient() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Client> }) =>
|
||||
clientsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
toast({ title: 'Cliente actualizado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar cliente',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteClient() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => clientsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
toast({ title: 'Cliente eliminado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar cliente',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
141
src/hooks/useContentPieces.ts
Normal file
141
src/hooks/useContentPieces.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
contentPiecesApi,
|
||||
ContentPiece,
|
||||
ContentType,
|
||||
ContentStatus,
|
||||
} from '@/services/api/projects.api';
|
||||
import { PaginationParams } from '@/services/api/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useContentPieces(
|
||||
params?: PaginationParams & {
|
||||
projectId?: string;
|
||||
type?: ContentType;
|
||||
status?: ContentStatus;
|
||||
search?: string;
|
||||
}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['content-pieces', params],
|
||||
queryFn: () => contentPiecesApi.getAll(params).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContentPiece(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['content-pieces', id],
|
||||
queryFn: () => contentPiecesApi.getById(id).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useContentPiecesByProject(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['content-pieces', 'project', projectId],
|
||||
queryFn: () => contentPiecesApi.getByProject(projectId).then((res) => res.data),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateContentPiece() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<ContentPiece>) => contentPiecesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content-pieces'] });
|
||||
toast({ title: 'Contenido creado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al crear contenido',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateContentPiece() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<ContentPiece> }) =>
|
||||
contentPiecesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content-pieces'] });
|
||||
toast({ title: 'Contenido actualizado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar contenido',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateContentPieceStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ContentStatus }) =>
|
||||
contentPiecesApi.updateStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content-pieces'] });
|
||||
toast({ title: 'Estado actualizado' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar estado',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateContentPiece() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentPiecesApi.duplicate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content-pieces'] });
|
||||
toast({ title: 'Contenido duplicado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al duplicar contenido',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteContentPiece() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentPiecesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content-pieces'] });
|
||||
toast({ title: 'Contenido eliminado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar contenido',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
101
src/hooks/useFolders.ts
Normal file
101
src/hooks/useFolders.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { foldersApi, AssetFolder } from '@/services/api/assets.api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useFolders(brandId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['folders', { brandId }],
|
||||
queryFn: () => foldersApi.getAll(brandId).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFolderTree(brandId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['folders', 'tree', { brandId }],
|
||||
queryFn: () => foldersApi.getTree(brandId).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRootFolders(brandId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['folders', 'root', { brandId }],
|
||||
queryFn: () => foldersApi.getRootFolders(brandId).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFolder(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['folders', id],
|
||||
queryFn: () => foldersApi.getById(id).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFolderBySlug(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['folders', 'slug', slug],
|
||||
queryFn: () => foldersApi.getBySlug(slug).then((res) => res.data),
|
||||
enabled: !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFolder() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<AssetFolder>) => foldersApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast({ title: 'Carpeta creada exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al crear carpeta',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFolder() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<AssetFolder> }) =>
|
||||
foldersApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast({ title: 'Carpeta actualizada exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar carpeta',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFolder() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => foldersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast({ title: 'Carpeta eliminada exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar carpeta',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
87
src/hooks/useProducts.ts
Normal file
87
src/hooks/useProducts.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { productsApi, Product, PaginationParams } from '@/services/api/crm.api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useProducts(params?: PaginationParams & { brandId?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['products', params],
|
||||
queryFn: () => productsApi.getAll(params).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProduct(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['products', id],
|
||||
queryFn: () => productsApi.getById(id).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProductsByBrand(brandId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['products', 'brand', brandId],
|
||||
queryFn: () => productsApi.getByBrand(brandId).then((res) => res.data),
|
||||
enabled: !!brandId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<Product>) => productsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast({ title: 'Producto creado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al crear producto',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Product> }) =>
|
||||
productsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast({ title: 'Producto actualizado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar producto',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => productsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
toast({ title: 'Producto eliminado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar producto',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
111
src/hooks/useProjects.ts
Normal file
111
src/hooks/useProjects.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { projectsApi, Project, ProjectStatus } from '@/services/api/projects.api';
|
||||
import { PaginationParams } from '@/services/api/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function useProjects(
|
||||
params?: PaginationParams & { brandId?: string; status?: ProjectStatus; search?: string }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['projects', params],
|
||||
queryFn: () => projectsApi.getAll(params).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProject(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['projects', id],
|
||||
queryFn: () => projectsApi.getById(id).then((res) => res.data),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectsByBrand(brandId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['projects', 'brand', brandId],
|
||||
queryFn: () => projectsApi.getByBrand(brandId).then((res) => res.data),
|
||||
enabled: !!brandId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<Project>) => projectsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast({ title: 'Proyecto creado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al crear proyecto',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Project> }) =>
|
||||
projectsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast({ title: 'Proyecto actualizado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar proyecto',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProjectStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ProjectStatus }) =>
|
||||
projectsApi.updateStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast({ title: 'Estado actualizado' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al actualizar estado',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProject() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => projectsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast({ title: 'Proyecto eliminado exitosamente' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Error al eliminar proyecto',
|
||||
description: error.response?.data?.message || 'Error desconocido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
60
src/index.css
Normal file
60
src/index.css
Normal file
@ -0,0 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
28
src/main.tsx
Normal file
28
src/main.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
450
src/pages/assets/AssetsPage.tsx
Normal file
450
src/pages/assets/AssetsPage.tsx
Normal file
@ -0,0 +1,450 @@
|
||||
import { useState } from 'react';
|
||||
import { useAssets, useDeleteAsset, useBulkDeleteAssets } from '@/hooks/useAssets';
|
||||
import { useBrands } from '@/hooks/useBrands';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Image,
|
||||
Video,
|
||||
FileText,
|
||||
Music,
|
||||
File,
|
||||
Trash2,
|
||||
Download,
|
||||
Eye,
|
||||
Grid,
|
||||
List,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { Asset } from '@/services/api/assets.api';
|
||||
|
||||
const assetTypeIcons: Record<string, typeof Image> = {
|
||||
image: Image,
|
||||
video: Video,
|
||||
document: FileText,
|
||||
audio: Music,
|
||||
other: File,
|
||||
};
|
||||
|
||||
const assetTypeLabels: Record<string, string> = {
|
||||
image: 'Imágenes',
|
||||
video: 'Videos',
|
||||
document: 'Documentos',
|
||||
audio: 'Audio',
|
||||
other: 'Otros',
|
||||
};
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export default function AssetsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [brandFilter, setBrandFilter] = useState<string>('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [selectedAssets, setSelectedAssets] = useState<string[]>([]);
|
||||
|
||||
const { data: brandsData } = useBrands({ limit: 100 });
|
||||
const { data, isLoading } = useAssets({
|
||||
search,
|
||||
page,
|
||||
limit: 24,
|
||||
type: typeFilter !== 'all' ? typeFilter : undefined,
|
||||
brandId: brandFilter !== 'all' ? brandFilter : undefined,
|
||||
});
|
||||
const deleteAsset = useDeleteAsset();
|
||||
const bulkDeleteAssets = useBulkDeleteAssets();
|
||||
|
||||
const handleSelectAsset = (id: string) => {
|
||||
setSelectedAssets((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (data?.data) {
|
||||
if (selectedAssets.length === data.data.length) {
|
||||
setSelectedAssets([]);
|
||||
} else {
|
||||
setSelectedAssets(data.data.map((a) => a.id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('¿Está seguro de eliminar este asset?')) {
|
||||
deleteAsset.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (confirm(`¿Está seguro de eliminar ${selectedAssets.length} assets?`)) {
|
||||
bulkDeleteAssets.mutate(selectedAssets);
|
||||
setSelectedAssets([]);
|
||||
}
|
||||
};
|
||||
|
||||
const renderAssetPreview = (asset: Asset) => {
|
||||
if (asset.type === 'image' && asset.url) {
|
||||
return (
|
||||
<img
|
||||
src={asset.thumbnail_url || asset.url}
|
||||
alt={asset.alt_text || asset.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = assetTypeIcons[asset.type] || File;
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||
<Icon className="h-12 w-12 text-muted-foreground/50" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Assets</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestiona imágenes, videos y documentos
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Subir Asset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar assets..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los tipos</SelectItem>
|
||||
{Object.entries(assetTypeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={brandFilter} onValueChange={setBrandFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Marca" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las marcas</SelectItem>
|
||||
{brandsData?.data?.map((brand) => (
|
||||
<SelectItem key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-1 border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Actions */}
|
||||
{selectedAssets.length > 0 && (
|
||||
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedAssets.length} seleccionados
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
{selectedAssets.length === data?.data?.length
|
||||
? 'Deseleccionar todos'
|
||||
: 'Seleccionar todos'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Eliminar seleccionados
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid/List View */}
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={
|
||||
viewMode === 'grid'
|
||||
? 'grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'
|
||||
: 'space-y-2'
|
||||
}
|
||||
>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<div
|
||||
className={
|
||||
viewMode === 'grid' ? 'aspect-square bg-muted' : 'h-16 bg-muted'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">No hay assets</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Comienza subiendo tu primer archivo
|
||||
</p>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Subir Asset
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{data?.data?.map((asset) => (
|
||||
<Card
|
||||
key={asset.id}
|
||||
className={`group cursor-pointer hover:shadow-md transition-all overflow-hidden ${
|
||||
selectedAssets.includes(asset.id) ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleSelectAsset(asset.id)}
|
||||
>
|
||||
<div className="aspect-square relative">
|
||||
{renderAssetPreview(asset)}
|
||||
{/* Selection Checkbox */}
|
||||
<div
|
||||
className={`absolute top-2 left-2 h-6 w-6 rounded-md border-2 flex items-center justify-center transition-all ${
|
||||
selectedAssets.includes(asset.id)
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-white/80 border-gray-300 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{selectedAssets.includes(asset.id) && (
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shadow"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(asset.url, '_blank');
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shadow"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const a = document.createElement('a');
|
||||
a.href = asset.url;
|
||||
a.download = asset.original_name;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shadow text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(asset.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<p className="text-sm font-medium truncate" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(asset.size)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data?.data?.map((asset) => {
|
||||
const Icon = assetTypeIcons[asset.type] || File;
|
||||
return (
|
||||
<Card
|
||||
key={asset.id}
|
||||
className={`group cursor-pointer hover:shadow-sm transition-all ${
|
||||
selectedAssets.includes(asset.id) ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleSelectAsset(asset.id)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-3">
|
||||
<div
|
||||
className={`h-6 w-6 rounded-md border-2 flex items-center justify-center ${
|
||||
selectedAssets.includes(asset.id)
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedAssets.includes(asset.id) && (
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
{asset.type === 'image' && asset.thumbnail_url ? (
|
||||
<img
|
||||
src={asset.thumbnail_url}
|
||||
alt={asset.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{asset.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{assetTypeLabels[asset.type]} - {formatFileSize(asset.size)}
|
||||
</p>
|
||||
</div>
|
||||
{asset.brand && (
|
||||
<span className="px-2 py-1 bg-muted rounded-md text-xs">
|
||||
{asset.brand.name}
|
||||
</span>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(asset.url, '_blank');
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const a = document.createElement('a');
|
||||
a.href = asset.url;
|
||||
a.download = asset.original_name;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(asset.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.meta && data.meta.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasPreviousPage}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {data.meta.page} de {data.meta.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasNextPage}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/pages/auth/LoginPage.tsx
Normal file
113
src/pages/auth/LoginPage.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import apiClient from '@/services/api/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email invalido'),
|
||||
password: z.string().min(1, 'Password requerido'),
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post('/auth/login', data);
|
||||
const { user, tokens } = response.data;
|
||||
|
||||
setAuth(user, tokens.accessToken, tokens.refreshToken);
|
||||
toast({
|
||||
title: 'Bienvenido',
|
||||
description: `Hola ${user.first_name || user.email}`,
|
||||
});
|
||||
navigate('/dashboard');
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.message || 'Credenciales invalidas',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto h-12 w-12 rounded-lg bg-primary flex items-center justify-center mb-4">
|
||||
<span className="text-primary-foreground font-bold text-2xl">P</span>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Platform Marketing Content</CardTitle>
|
||||
<CardDescription>Ingresa tus credenciales para continuar</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
432
src/pages/crm/BrandFormPage.tsx
Normal file
432
src/pages/crm/BrandFormPage.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useBrand, useCreateBrand, useUpdateBrand } from '@/hooks/useBrands';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
|
||||
const brandSchema = z.object({
|
||||
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||
slug: z.string().optional(),
|
||||
client_id: z.string().min(1, 'Selecciona un cliente'),
|
||||
description: z.string().optional(),
|
||||
logo_url: z.string().url('URL inválida').optional().or(z.literal('')),
|
||||
primary_color: z.string().optional(),
|
||||
secondary_color: z.string().optional(),
|
||||
brand_voice: z.string().optional(),
|
||||
target_audience: z.string().optional(),
|
||||
keywords: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
});
|
||||
|
||||
type BrandFormData = z.infer<typeof brandSchema>;
|
||||
|
||||
export default function BrandFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedClientId = searchParams.get('clientId');
|
||||
const isEditing = !!id;
|
||||
|
||||
const { data: brand, isLoading: isLoadingBrand } = useBrand(id || '');
|
||||
const { data: clientsData, isLoading: isLoadingClients } = useClients({
|
||||
limit: 100,
|
||||
});
|
||||
const createBrand = useCreateBrand();
|
||||
const updateBrand = useUpdateBrand();
|
||||
|
||||
const form = useForm<BrandFormData>({
|
||||
resolver: zodResolver(brandSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
slug: '',
|
||||
client_id: preselectedClientId || '',
|
||||
description: '',
|
||||
logo_url: '',
|
||||
primary_color: '#3B82F6',
|
||||
secondary_color: '#10B981',
|
||||
brand_voice: '',
|
||||
target_audience: '',
|
||||
keywords: '',
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (brand) {
|
||||
form.reset({
|
||||
name: brand.name || '',
|
||||
slug: brand.slug || '',
|
||||
client_id: brand.client_id || '',
|
||||
description: brand.description || '',
|
||||
logo_url: brand.logo_url || '',
|
||||
primary_color: brand.primary_color || '#3B82F6',
|
||||
secondary_color: brand.secondary_color || '#10B981',
|
||||
brand_voice: brand.brand_voice || '',
|
||||
target_audience: brand.target_audience || '',
|
||||
keywords: Array.isArray(brand.keywords)
|
||||
? brand.keywords.join(', ')
|
||||
: '',
|
||||
is_active: brand.is_active ?? true,
|
||||
});
|
||||
}
|
||||
}, [brand, form]);
|
||||
|
||||
const onSubmit = async (data: BrandFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
logo_url: data.logo_url || undefined,
|
||||
keywords: data.keywords
|
||||
? data.keywords.split(',').map((k) => k.trim())
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
await updateBrand.mutateAsync({ id, data: cleanData });
|
||||
} else {
|
||||
await createBrand.mutateAsync(cleanData);
|
||||
}
|
||||
navigate('/crm/brands');
|
||||
};
|
||||
|
||||
if (isEditing && isLoadingBrand) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isEditing ? 'Editar Marca' : 'Nueva Marca'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditing
|
||||
? 'Modifica los datos de la marca'
|
||||
: 'Completa los datos para crear una nueva marca'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información General</CardTitle>
|
||||
<CardDescription>Datos básicos de la marca</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="client_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cliente *</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={isLoadingClients}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un cliente" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{clientsData?.data?.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Nombre de la marca" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="marca-slug" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Se genera automáticamente si se deja vacío
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL del Logo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://ejemplo.com/logo.png"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Descripción</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Descripción de la marca..."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Identidad Visual</CardTitle>
|
||||
<CardDescription>Colores de la marca</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="primary_color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Color Primario</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input type="color" className="w-14 h-10" {...field} />
|
||||
</FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="#3B82F6"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secondary_color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Color Secundario</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input type="color" className="w-14 h-10" {...field} />
|
||||
</FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="#10B981"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Voz y Audiencia</CardTitle>
|
||||
<CardDescription>
|
||||
Define cómo se comunica la marca y a quién va dirigida
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brand_voice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Voz de Marca</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe el tono y estilo de comunicación de la marca..."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Ej: Profesional pero cercano, innovador, confiable
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="target_audience"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audiencia Objetivo</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe el público objetivo de la marca..."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Ej: Profesionales de 25-45 años interesados en tecnología
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Palabras Clave</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="innovación, calidad, tecnología"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Separadas por comas, se usarán para generación de contenido
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuración</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Marca Activa</FormLabel>
|
||||
<FormDescription>
|
||||
Las marcas inactivas no aparecen en las listas de
|
||||
selección
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/crm/brands')}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createBrand.isPending || updateBrand.isPending}
|
||||
>
|
||||
{(createBrand.isPending || updateBrand.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isEditing ? 'Guardar Cambios' : 'Crear Marca'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/pages/crm/BrandsPage.tsx
Normal file
219
src/pages/crm/BrandsPage.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBrands, useDeleteBrand } from '@/hooks/useBrands';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Plus, Search, Palette, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function BrandsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data, isLoading } = useBrands({ search, page, limit: 12 });
|
||||
const deleteBrand = useDeleteBrand();
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('¿Está seguro de eliminar esta marca?')) {
|
||||
deleteBrand.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Marcas</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestiona las marcas de tus clientes
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/crm/brands/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva Marca
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar marcas..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-2/3" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Palette className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">No hay marcas</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Comienza agregando tu primera marca
|
||||
</p>
|
||||
<Link to="/crm/brands/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva Marca
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.data?.map((brand) => (
|
||||
<Card key={brand.id} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{brand.logo_url ? (
|
||||
<img
|
||||
src={brand.logo_url}
|
||||
alt={brand.name}
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-10 w-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: brand.primary_color || '#3B82F6',
|
||||
}}
|
||||
>
|
||||
<span className="text-white font-bold text-lg">
|
||||
{brand.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
<Link
|
||||
to={`/crm/brands/${brand.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{brand.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{brand.client && (
|
||||
<CardDescription>
|
||||
<Link
|
||||
to={`/crm/clients/${brand.client.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{brand.client.name}
|
||||
</Link>
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Link to={`/crm/brands/${brand.id}/edit`}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDelete(brand.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{brand.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{brand.description}
|
||||
</p>
|
||||
)}
|
||||
{(brand.primary_color || brand.secondary_color) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{brand.primary_color && (
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border"
|
||||
style={{ backgroundColor: brand.primary_color }}
|
||||
title="Color primario"
|
||||
/>
|
||||
)}
|
||||
{brand.secondary_color && (
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border"
|
||||
style={{ backgroundColor: brand.secondary_color }}
|
||||
title="Color secundario"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
brand.is_active
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{brand.is_active ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.meta && data.meta.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasPreviousPage}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {data.meta.page} de {data.meta.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasNextPage}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
398
src/pages/crm/ClientFormPage.tsx
Normal file
398
src/pages/crm/ClientFormPage.tsx
Normal file
@ -0,0 +1,398 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useClient, useCreateClient, useUpdateClient } from '@/hooks/useClients';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
|
||||
const clientSchema = z.object({
|
||||
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||
slug: z.string().optional(),
|
||||
type: z.enum(['company', 'individual']),
|
||||
industry: z.string().optional(),
|
||||
website: z.string().url('URL inválida').optional().or(z.literal('')),
|
||||
logo_url: z.string().url('URL inválida').optional().or(z.literal('')),
|
||||
contact_name: z.string().optional(),
|
||||
contact_email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||
contact_phone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
});
|
||||
|
||||
type ClientFormData = z.infer<typeof clientSchema>;
|
||||
|
||||
export default function ClientFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditing = !!id;
|
||||
|
||||
const { data: client, isLoading: isLoadingClient } = useClient(id || '');
|
||||
const createClient = useCreateClient();
|
||||
const updateClient = useUpdateClient();
|
||||
|
||||
const form = useForm<ClientFormData>({
|
||||
resolver: zodResolver(clientSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
slug: '',
|
||||
type: 'company',
|
||||
industry: '',
|
||||
website: '',
|
||||
logo_url: '',
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
address: '',
|
||||
notes: '',
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
form.reset({
|
||||
name: client.name || '',
|
||||
slug: client.slug || '',
|
||||
type: client.type || 'company',
|
||||
industry: client.industry || '',
|
||||
website: client.website || '',
|
||||
logo_url: client.logo_url || '',
|
||||
contact_name: client.contact_name || '',
|
||||
contact_email: client.contact_email || '',
|
||||
contact_phone: client.contact_phone || '',
|
||||
address: client.address || '',
|
||||
notes: client.notes || '',
|
||||
is_active: client.is_active ?? true,
|
||||
});
|
||||
}
|
||||
}, [client, form]);
|
||||
|
||||
const onSubmit = async (data: ClientFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
website: data.website || undefined,
|
||||
logo_url: data.logo_url || undefined,
|
||||
contact_email: data.contact_email || undefined,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
await updateClient.mutateAsync({ id, data: cleanData });
|
||||
} else {
|
||||
await createClient.mutateAsync(cleanData);
|
||||
}
|
||||
navigate('/crm/clients');
|
||||
};
|
||||
|
||||
if (isEditing && isLoadingClient) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isEditing ? 'Editar Cliente' : 'Nuevo Cliente'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditing
|
||||
? 'Modifica los datos del cliente'
|
||||
: 'Completa los datos para crear un nuevo cliente'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información General</CardTitle>
|
||||
<CardDescription>Datos básicos del cliente</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Nombre del cliente" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="cliente-slug" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Se genera automáticamente si se deja vacío
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tipo *</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona el tipo" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="company">Empresa</SelectItem>
|
||||
<SelectItem value="individual">Individual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="industry"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Industria</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ej: Tecnología, Retail" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sitio Web</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://ejemplo.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL del Logo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://ejemplo.com/logo.png"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información de Contacto</CardTitle>
|
||||
<CardDescription>Datos del contacto principal</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contact_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre del Contacto</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Juan Pérez" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contact_email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="contacto@ejemplo.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contact_phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teléfono</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="+52 55 1234 5678" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Dirección</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Calle, Ciudad, País" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuración</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notas</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Notas adicionales sobre el cliente..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Cliente Activo</FormLabel>
|
||||
<FormDescription>
|
||||
Los clientes inactivos no aparecen en las listas de
|
||||
selección
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/crm/clients')}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createClient.isPending || updateClient.isPending}
|
||||
>
|
||||
{(createClient.isPending || updateClient.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isEditing ? 'Guardar Cambios' : 'Crear Cliente'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/pages/crm/ClientsPage.tsx
Normal file
206
src/pages/crm/ClientsPage.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useClients, useDeleteClient } from '@/hooks/useClients';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Building2,
|
||||
Mail,
|
||||
Phone,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function ClientsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data, isLoading } = useClients({ search, page, limit: 12 });
|
||||
const deleteClient = useDeleteClient();
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('¿Está seguro de eliminar este cliente?')) {
|
||||
deleteClient.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Clientes</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestiona tus clientes y sus marcas
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/crm/clients/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Cliente
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar clientes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-2/3" />
|
||||
<div className="h-4 bg-muted rounded w-1/2 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">No hay clientes</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Comienza agregando tu primer cliente
|
||||
</p>
|
||||
<Link to="/crm/clients/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Cliente
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.data?.map((client) => (
|
||||
<Card key={client.id} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{client.logo_url ? (
|
||||
<img
|
||||
src={client.logo_url}
|
||||
alt={client.name}
|
||||
className="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
<Link
|
||||
to={`/crm/clients/${client.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{client.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{client.industry && (
|
||||
<CardDescription>{client.industry}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Link to={`/crm/clients/${client.id}/edit`}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDelete(client.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{client.contact_email && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Mail className="h-4 w-4" />
|
||||
<span className="truncate">{client.contact_email}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.contact_phone && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Phone className="h-4 w-4" />
|
||||
<span>{client.contact_phone}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 flex items-center justify-between">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
client.is_active
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{client.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{client.type === 'company' ? 'Empresa' : 'Individual'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.meta && data.meta.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasPreviousPage}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {data.meta.page} de {data.meta.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasNextPage}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
489
src/pages/crm/ProductFormPage.tsx
Normal file
489
src/pages/crm/ProductFormPage.tsx
Normal file
@ -0,0 +1,489 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useProduct, useCreateProduct, useUpdateProduct } from '@/hooks/useProducts';
|
||||
import { useBrands } from '@/hooks/useBrands';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ArrowLeft, Loader2, Plus, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
const productSchema = z.object({
|
||||
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
|
||||
slug: z.string().optional(),
|
||||
brand_id: z.string().min(1, 'Selecciona una marca'),
|
||||
description: z.string().optional(),
|
||||
sku: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
price: z.coerce.number().min(0).optional().nullable(),
|
||||
currency: z.string().default('MXN'),
|
||||
features: z.string().optional(),
|
||||
benefits: z.string().optional(),
|
||||
is_active: z.boolean(),
|
||||
});
|
||||
|
||||
type ProductFormData = z.infer<typeof productSchema>;
|
||||
|
||||
export default function ProductFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedBrandId = searchParams.get('brandId');
|
||||
const isEditing = !!id;
|
||||
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
const [newImageUrl, setNewImageUrl] = useState('');
|
||||
|
||||
const { data: product, isLoading: isLoadingProduct } = useProduct(id || '');
|
||||
const { data: brandsData, isLoading: isLoadingBrands } = useBrands({
|
||||
limit: 100,
|
||||
});
|
||||
const createProduct = useCreateProduct();
|
||||
const updateProduct = useUpdateProduct();
|
||||
|
||||
const form = useForm<ProductFormData>({
|
||||
resolver: zodResolver(productSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
slug: '',
|
||||
brand_id: preselectedBrandId || '',
|
||||
description: '',
|
||||
sku: '',
|
||||
category: '',
|
||||
price: null,
|
||||
currency: 'MXN',
|
||||
features: '',
|
||||
benefits: '',
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
form.reset({
|
||||
name: product.name || '',
|
||||
slug: product.slug || '',
|
||||
brand_id: product.brand_id || '',
|
||||
description: product.description || '',
|
||||
sku: product.sku || '',
|
||||
category: product.category || '',
|
||||
price: product.price,
|
||||
currency: product.currency || 'MXN',
|
||||
features: Array.isArray(product.features)
|
||||
? product.features.join('\n')
|
||||
: '',
|
||||
benefits: Array.isArray(product.benefits)
|
||||
? product.benefits.join('\n')
|
||||
: '',
|
||||
is_active: product.is_active ?? true,
|
||||
});
|
||||
setImageUrls(product.image_urls || []);
|
||||
}
|
||||
}, [product, form]);
|
||||
|
||||
const handleAddImage = () => {
|
||||
if (newImageUrl && !imageUrls.includes(newImageUrl)) {
|
||||
setImageUrls([...imageUrls, newImageUrl]);
|
||||
setNewImageUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = (url: string) => {
|
||||
setImageUrls(imageUrls.filter((u) => u !== url));
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ProductFormData) => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
price: data.price || null,
|
||||
image_urls: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
features: data.features
|
||||
? data.features.split('\n').filter((f) => f.trim())
|
||||
: undefined,
|
||||
benefits: data.benefits
|
||||
? data.benefits.split('\n').filter((b) => b.trim())
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
await updateProduct.mutateAsync({ id, data: cleanData });
|
||||
} else {
|
||||
await createProduct.mutateAsync(cleanData);
|
||||
}
|
||||
navigate('/crm/products');
|
||||
};
|
||||
|
||||
if (isEditing && isLoadingProduct) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isEditing ? 'Editar Producto' : 'Nuevo Producto'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditing
|
||||
? 'Modifica los datos del producto'
|
||||
: 'Completa los datos para crear un nuevo producto'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información General</CardTitle>
|
||||
<CardDescription>Datos básicos del producto</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brand_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Marca *</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={isLoadingBrands}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una marca" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{brandsData?.data?.map((brand) => (
|
||||
<SelectItem key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Nombre del producto" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="producto-slug" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Se genera automáticamente si se deja vacío
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sku"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SKU</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="PROD-001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Categoría</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ej: Electrónicos" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Precio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Moneda</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="MXN">MXN</SelectItem>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Descripción</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Descripción del producto..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Imágenes</CardTitle>
|
||||
<CardDescription>Agrega URLs de imágenes del producto</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://ejemplo.com/imagen.jpg"
|
||||
value={newImageUrl}
|
||||
onChange={(e) => setNewImageUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddImage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" onClick={handleAddImage}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{imageUrls.length > 0 && (
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
{imageUrls.map((url, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative aspect-square rounded-lg border overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Imagen ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleRemoveImage(url)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Características y Beneficios</CardTitle>
|
||||
<CardDescription>
|
||||
Información para generación de contenido
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="features"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Características</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Una característica por línea..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Lista las características principales, una por línea
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="benefits"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Beneficios</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Un beneficio por línea..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Lista los beneficios para el usuario, uno por línea
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuración</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Producto Activo</FormLabel>
|
||||
<FormDescription>
|
||||
Los productos inactivos no aparecen en las listas de
|
||||
selección
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/crm/products')}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createProduct.isPending || updateProduct.isPending}
|
||||
>
|
||||
{(createProduct.isPending || updateProduct.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isEditing ? 'Guardar Cambios' : 'Crear Producto'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/pages/crm/ProductsPage.tsx
Normal file
206
src/pages/crm/ProductsPage.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useProducts, useDeleteProduct } from '@/hooks/useProducts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Plus, Search, Package, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data, isLoading } = useProducts({ search, page, limit: 12 });
|
||||
const deleteProduct = useDeleteProduct();
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('¿Está seguro de eliminar este producto?')) {
|
||||
deleteProduct.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number | null, currency: string) => {
|
||||
if (price === null) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Productos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestiona los productos de tus marcas
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/crm/products/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Producto
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar productos..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<div className="aspect-square bg-muted" />
|
||||
<CardHeader>
|
||||
<div className="h-5 bg-muted rounded w-2/3" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">No hay productos</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Comienza agregando tu primer producto
|
||||
</p>
|
||||
<Link to="/crm/products/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Producto
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{data?.data?.map((product) => (
|
||||
<Card key={product.id} className="group hover:shadow-md transition-shadow overflow-hidden">
|
||||
{/* Image */}
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{product.image_urls && product.image_urls.length > 0 ? (
|
||||
<img
|
||||
src={product.image_urls[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Package className="h-16 w-16 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
{/* Actions Overlay */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Link to={`/crm/products/${product.id}/edit`}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shadow"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shadow text-destructive"
|
||||
onClick={() => handleDelete(product.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base line-clamp-1">
|
||||
<Link
|
||||
to={`/crm/products/${product.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{product.brand && (
|
||||
<CardDescription className="text-xs">
|
||||
<Link
|
||||
to={`/crm/brands/${product.brand.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.brand.name}
|
||||
</Link>
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-lg">
|
||||
{formatPrice(product.price, product.currency)}
|
||||
</span>
|
||||
{product.sku && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
SKU: {product.sku}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{product.category && (
|
||||
<span className="inline-block px-2 py-1 bg-muted rounded-md text-xs">
|
||||
{product.category}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.meta && data.meta.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasPreviousPage}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {data.meta.page} de {data.meta.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasNextPage}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/pages/dashboard/DashboardPage.tsx
Normal file
105
src/pages/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { Users, FolderKanban, Wand2, Image } from 'lucide-react';
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Clientes',
|
||||
value: '0',
|
||||
icon: Users,
|
||||
description: 'Clientes activos',
|
||||
},
|
||||
{
|
||||
name: 'Proyectos',
|
||||
value: '0',
|
||||
icon: FolderKanban,
|
||||
description: 'Proyectos en curso',
|
||||
},
|
||||
{
|
||||
name: 'Generaciones',
|
||||
value: '0',
|
||||
icon: Wand2,
|
||||
description: 'Este mes',
|
||||
},
|
||||
{
|
||||
name: 'Assets',
|
||||
value: '0',
|
||||
icon: Image,
|
||||
description: 'En biblioteca',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">
|
||||
Hola, {user?.first_name || 'Usuario'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Bienvenido a Platform Marketing Content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.name}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stat.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actividad Reciente</CardTitle>
|
||||
<CardDescription>Ultimas acciones en la plataforma</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No hay actividad reciente
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uso del Plan</CardTitle>
|
||||
<CardDescription>Consumo del mes actual</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Generaciones</span>
|
||||
<span>0 / 100</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted">
|
||||
<div className="h-2 rounded-full bg-primary" style={{ width: '0%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
src/pages/projects/ProjectsPage.tsx
Normal file
309
src/pages/projects/ProjectsPage.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useProjects, useDeleteProject } from '@/hooks/useProjects';
|
||||
import { useBrands } from '@/hooks/useBrands';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
FolderKanban,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Calendar,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { ProjectStatus, ProjectPriority } from '@/services/api/projects.api';
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
const statusLabels: Record<ProjectStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
planning: 'Planificación',
|
||||
in_progress: 'En Progreso',
|
||||
review: 'En Revisión',
|
||||
completed: 'Completado',
|
||||
archived: 'Archivado',
|
||||
};
|
||||
|
||||
const statusColors: Record<ProjectStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
planning: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
review: 'bg-purple-100 text-purple-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
const priorityLabels: Record<ProjectPriority, string> = {
|
||||
low: 'Baja',
|
||||
medium: 'Media',
|
||||
high: 'Alta',
|
||||
urgent: 'Urgente',
|
||||
};
|
||||
|
||||
const priorityColors: Record<ProjectPriority, string> = {
|
||||
low: 'text-gray-500',
|
||||
medium: 'text-blue-500',
|
||||
high: 'text-orange-500',
|
||||
urgent: 'text-red-500',
|
||||
};
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [brandFilter, setBrandFilter] = useState<string>('all');
|
||||
|
||||
const { data: brandsData } = useBrands({ limit: 100 });
|
||||
const { data, isLoading } = useProjects({
|
||||
search,
|
||||
page,
|
||||
limit: 12,
|
||||
status: statusFilter !== 'all' ? (statusFilter as ProjectStatus) : undefined,
|
||||
brandId: brandFilter !== 'all' ? brandFilter : undefined,
|
||||
});
|
||||
const deleteProject = useDeleteProject();
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('¿Está seguro de eliminar este proyecto?')) {
|
||||
deleteProject.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Proyectos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestiona tus proyectos de contenido
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/projects/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Proyecto
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar proyectos..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Estado" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los estados</SelectItem>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={brandFilter} onValueChange={setBrandFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Marca" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las marcas</SelectItem>
|
||||
{brandsData?.data?.map((brand) => (
|
||||
<SelectItem key={brand.id} value={brand.id}>
|
||||
{brand.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-muted rounded w-2/3" />
|
||||
<div className="h-4 bg-muted rounded w-1/2 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : data?.data?.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">No hay proyectos</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Comienza creando tu primer proyecto
|
||||
</p>
|
||||
<Link to="/projects/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Proyecto
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.data?.map((project) => (
|
||||
<Card key={project.id} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">
|
||||
<Link
|
||||
to={`/projects/${project.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{project.brand && (
|
||||
<CardDescription>
|
||||
<Link
|
||||
to={`/crm/brands/${project.brand.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{project.brand.name}
|
||||
</Link>
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Link to={`/projects/${project.id}/edit`}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDelete(project.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progreso</span>
|
||||
<span className="font-medium">{project.progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{project.due_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{format(new Date(project.due_date), 'dd MMM', { locale: es })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{project.owner && (
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{project.owner.first_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & Priority */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
statusColors[project.status]
|
||||
}`}
|
||||
>
|
||||
{statusLabels[project.status]}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
priorityColors[project.priority]
|
||||
}`}
|
||||
>
|
||||
{priorityLabels[project.priority]}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data?.meta && data.meta.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasPreviousPage}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {data.meta.page} de {data.meta.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.meta.hasNextPage}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/services/api/assets.api.ts
Normal file
95
src/services/api/assets.api.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { apiClient, PaginatedResponse, PaginationParams } from './client';
|
||||
|
||||
export interface Asset {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
brand_id: string | null;
|
||||
brand?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
name: string;
|
||||
original_name: string;
|
||||
type: 'image' | 'video' | 'document' | 'audio' | 'other';
|
||||
mime_type: string;
|
||||
size: number;
|
||||
url: string;
|
||||
thumbnail_url: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
duration: number | null;
|
||||
alt_text: string | null;
|
||||
description: string | null;
|
||||
tags: string[] | null;
|
||||
metadata: Record<string, any> | null;
|
||||
status: 'uploading' | 'processing' | 'ready' | 'error';
|
||||
storage_path: string | null;
|
||||
storage_provider: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AssetFolder {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
brand_id: string | null;
|
||||
brand?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
parent_id: string | null;
|
||||
parent?: AssetFolder;
|
||||
children?: AssetFolder[];
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
path: string | null;
|
||||
level: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const assetsApi = {
|
||||
// Assets
|
||||
getAll: (params?: PaginationParams & { brandId?: string; type?: string; search?: string }) =>
|
||||
apiClient.get<PaginatedResponse<Asset>>('/assets', { params }),
|
||||
|
||||
getById: (id: string) => apiClient.get<Asset>(`/assets/${id}`),
|
||||
|
||||
getByBrand: (brandId: string) => apiClient.get<Asset[]>(`/assets/brand/${brandId}`),
|
||||
|
||||
create: (data: Partial<Asset>) => apiClient.post<Asset>('/assets', data),
|
||||
|
||||
update: (id: string, data: Partial<Asset>) => apiClient.put<Asset>(`/assets/${id}`, data),
|
||||
|
||||
updateStatus: (id: string, status: Asset['status']) =>
|
||||
apiClient.put<Asset>(`/assets/${id}/status`, { status }),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/assets/${id}`),
|
||||
|
||||
bulkDelete: (ids: string[]) => apiClient.delete('/assets/bulk', { data: { ids } }),
|
||||
};
|
||||
|
||||
export const foldersApi = {
|
||||
// Folders
|
||||
getAll: (brandId?: string) =>
|
||||
apiClient.get<AssetFolder[]>('/assets/folders', { params: { brandId } }),
|
||||
|
||||
getTree: (brandId?: string) =>
|
||||
apiClient.get<AssetFolder[]>('/assets/folders/tree', { params: { brandId } }),
|
||||
|
||||
getRootFolders: (brandId?: string) =>
|
||||
apiClient.get<AssetFolder[]>('/assets/folders/root', { params: { brandId } }),
|
||||
|
||||
getById: (id: string) => apiClient.get<AssetFolder>(`/assets/folders/${id}`),
|
||||
|
||||
getBySlug: (slug: string) => apiClient.get<AssetFolder>(`/assets/folders/slug/${slug}`),
|
||||
|
||||
create: (data: Partial<AssetFolder>) => apiClient.post<AssetFolder>('/assets/folders', data),
|
||||
|
||||
update: (id: string, data: Partial<AssetFolder>) =>
|
||||
apiClient.put<AssetFolder>(`/assets/folders/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/assets/folders/${id}`),
|
||||
};
|
||||
90
src/services/api/client.ts
Normal file
90
src/services/api/client.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1';
|
||||
|
||||
// Pagination types
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const { accessToken } = useAuthStore.getState();
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor - handle errors and token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// If 401 and not already retrying, try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const { refreshToken, logout, setAuth } = useAuthStore.getState();
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
const { tokens } = response.data;
|
||||
const { user } = useAuthStore.getState();
|
||||
|
||||
if (user) {
|
||||
setAuth(user, tokens.accessToken, tokens.refreshToken);
|
||||
}
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch {
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else {
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
139
src/services/api/crm.api.ts
Normal file
139
src/services/api/crm.api.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface Client {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: 'company' | 'individual';
|
||||
industry: string | null;
|
||||
website: string | null;
|
||||
logo_url: string | null;
|
||||
contact_name: string | null;
|
||||
contact_email: string | null;
|
||||
contact_phone: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
client_id: string;
|
||||
client?: Client;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
logo_url: string | null;
|
||||
primary_color: string | null;
|
||||
secondary_color: string | null;
|
||||
brand_voice: string | null;
|
||||
target_audience: string | null;
|
||||
keywords: string[] | null;
|
||||
guidelines_url: string | null;
|
||||
is_active: boolean;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
brand_id: string;
|
||||
brand?: Brand;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
sku: string | null;
|
||||
category: string | null;
|
||||
price: number | null;
|
||||
currency: string;
|
||||
image_urls: string[] | null;
|
||||
features: string[] | null;
|
||||
benefits: string[] | null;
|
||||
attributes: Record<string, any> | null;
|
||||
is_active: boolean;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
// Clients API
|
||||
export const clientsApi = {
|
||||
getAll: (params?: PaginationParams) =>
|
||||
apiClient.get<PaginatedResponse<Client>>('/crm/clients', { params }),
|
||||
|
||||
getById: (id: string) => apiClient.get<Client>(`/crm/clients/${id}`),
|
||||
|
||||
create: (data: Partial<Client>) =>
|
||||
apiClient.post<Client>('/crm/clients', data),
|
||||
|
||||
update: (id: string, data: Partial<Client>) =>
|
||||
apiClient.put<Client>(`/crm/clients/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/crm/clients/${id}`),
|
||||
};
|
||||
|
||||
// Brands API
|
||||
export const brandsApi = {
|
||||
getAll: (params?: PaginationParams & { clientId?: string }) =>
|
||||
apiClient.get<PaginatedResponse<Brand>>('/crm/brands', { params }),
|
||||
|
||||
getById: (id: string) => apiClient.get<Brand>(`/crm/brands/${id}`),
|
||||
|
||||
getByClient: (clientId: string) =>
|
||||
apiClient.get<Brand[]>(`/crm/brands/client/${clientId}`),
|
||||
|
||||
create: (data: Partial<Brand>) =>
|
||||
apiClient.post<Brand>('/crm/brands', data),
|
||||
|
||||
update: (id: string, data: Partial<Brand>) =>
|
||||
apiClient.put<Brand>(`/crm/brands/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/crm/brands/${id}`),
|
||||
};
|
||||
|
||||
// Products API
|
||||
export const productsApi = {
|
||||
getAll: (params?: PaginationParams & { brandId?: string }) =>
|
||||
apiClient.get<PaginatedResponse<Product>>('/crm/products', { params }),
|
||||
|
||||
getById: (id: string) => apiClient.get<Product>(`/crm/products/${id}`),
|
||||
|
||||
getByBrand: (brandId: string) =>
|
||||
apiClient.get<Product[]>(`/crm/products/brand/${brandId}`),
|
||||
|
||||
create: (data: Partial<Product>) =>
|
||||
apiClient.post<Product>('/crm/products', data),
|
||||
|
||||
update: (id: string, data: Partial<Product>) =>
|
||||
apiClient.put<Product>(`/crm/products/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/crm/products/${id}`),
|
||||
};
|
||||
128
src/services/api/projects.api.ts
Normal file
128
src/services/api/projects.api.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { apiClient, PaginatedResponse, PaginationParams } from './client';
|
||||
|
||||
export type ProjectStatus = 'draft' | 'planning' | 'in_progress' | 'review' | 'completed' | 'archived';
|
||||
export type ProjectPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type ContentType = 'social_post' | 'blog_article' | 'email' | 'ad_copy' | 'landing_page' | 'video_script' | 'product_description' | 'other';
|
||||
export type ContentStatus = 'idea' | 'drafting' | 'review' | 'approved' | 'scheduled' | 'published' | 'archived';
|
||||
export type SocialPlatform = 'facebook' | 'instagram' | 'twitter' | 'linkedin' | 'tiktok' | 'youtube' | 'pinterest';
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
brand_id: string;
|
||||
brand?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
owner_id: string | null;
|
||||
owner?: {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
};
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
brief: string | null;
|
||||
status: ProjectStatus;
|
||||
priority: ProjectPriority;
|
||||
start_date: string | null;
|
||||
due_date: string | null;
|
||||
completed_at: string | null;
|
||||
tags: string[] | null;
|
||||
settings: Record<string, any> | null;
|
||||
progress: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ContentPiece {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
project?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
assigned_to: string | null;
|
||||
assignee?: {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
};
|
||||
title: string;
|
||||
type: ContentType;
|
||||
status: ContentStatus;
|
||||
platforms: SocialPlatform[] | null;
|
||||
content: string | null;
|
||||
content_html: string | null;
|
||||
prompt_used: string | null;
|
||||
ai_metadata: Record<string, any> | null;
|
||||
asset_ids: string[] | null;
|
||||
call_to_action: string | null;
|
||||
hashtags: string[] | null;
|
||||
scheduled_at: string | null;
|
||||
published_at: string | null;
|
||||
published_url: string | null;
|
||||
version: number;
|
||||
tags: string[] | null;
|
||||
metadata: Record<string, any> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
getAll: (params?: PaginationParams & { brandId?: string; status?: ProjectStatus; search?: string }) =>
|
||||
apiClient.get<PaginatedResponse<Project>>('/projects', { params }),
|
||||
|
||||
getById: (id: string) => apiClient.get<Project>(`/projects/${id}`),
|
||||
|
||||
getByBrand: (brandId: string) => apiClient.get<Project[]>(`/projects/brand/${brandId}`),
|
||||
|
||||
create: (data: Partial<Project>) => apiClient.post<Project>('/projects', data),
|
||||
|
||||
update: (id: string, data: Partial<Project>) => apiClient.put<Project>(`/projects/${id}`, data),
|
||||
|
||||
updateStatus: (id: string, status: ProjectStatus) =>
|
||||
apiClient.put<Project>(`/projects/${id}/status`, { status }),
|
||||
|
||||
updateProgress: (id: string, progress: number) =>
|
||||
apiClient.put<Project>(`/projects/${id}/progress`, { progress }),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/projects/${id}`),
|
||||
};
|
||||
|
||||
export const contentPiecesApi = {
|
||||
getAll: (params?: PaginationParams & { projectId?: string; type?: ContentType; status?: ContentStatus; search?: string }) =>
|
||||
apiClient.get<PaginatedResponse<ContentPiece>>('/content-pieces', { params }),
|
||||
|
||||
getById: (id: string) => apiClient.get<ContentPiece>(`/content-pieces/${id}`),
|
||||
|
||||
getByProject: (projectId: string) =>
|
||||
apiClient.get<ContentPiece[]>(`/content-pieces/project/${projectId}`),
|
||||
|
||||
create: (data: Partial<ContentPiece>) =>
|
||||
apiClient.post<ContentPiece>('/content-pieces', data),
|
||||
|
||||
update: (id: string, data: Partial<ContentPiece>) =>
|
||||
apiClient.put<ContentPiece>(`/content-pieces/${id}`, data),
|
||||
|
||||
updateStatus: (id: string, status: ContentStatus) =>
|
||||
apiClient.put<ContentPiece>(`/content-pieces/${id}/status`, { status }),
|
||||
|
||||
assign: (id: string, userId: string | null) =>
|
||||
apiClient.put<ContentPiece>(`/content-pieces/${id}/assign`, { userId }),
|
||||
|
||||
schedule: (id: string, scheduledAt: string) =>
|
||||
apiClient.put<ContentPiece>(`/content-pieces/${id}/schedule`, { scheduledAt }),
|
||||
|
||||
publish: (id: string, publishedUrl?: string) =>
|
||||
apiClient.put<ContentPiece>(`/content-pieces/${id}/publish`, { publishedUrl }),
|
||||
|
||||
duplicate: (id: string) =>
|
||||
apiClient.post<ContentPiece>(`/content-pieces/${id}/duplicate`),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/content-pieces/${id}`),
|
||||
};
|
||||
60
src/stores/useAuthStore.ts
Normal file
60
src/stores/useAuthStore.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
|
||||
updateUser: (user: Partial<User>) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (user, accessToken, refreshToken) =>
|
||||
set({
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
updateUser: (userData) =>
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...userData } : null,
|
||||
})),
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'pmc-auth-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
71
tailwind.config.js
Normal file
71
tailwind.config.js
Normal file
@ -0,0 +1,71 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/common/Layout/AuthLayout.tsx","./src/components/common/Layout/Header.tsx","./src/components/common/Layout/MainLayout.tsx","./src/components/common/Layout/Sidebar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/hooks/use-toast.ts","./src/hooks/useAssets.ts","./src/hooks/useBrands.ts","./src/hooks/useClients.ts","./src/hooks/useContentPieces.ts","./src/hooks/useFolders.ts","./src/hooks/useProducts.ts","./src/hooks/useProjects.ts","./src/lib/utils.ts","./src/pages/assets/AssetsPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/crm/BrandFormPage.tsx","./src/pages/crm/BrandsPage.tsx","./src/pages/crm/ClientFormPage.tsx","./src/pages/crm/ClientsPage.tsx","./src/pages/crm/ProductFormPage.tsx","./src/pages/crm/ProductsPage.tsx","./src/pages/dashboard/DashboardPage.tsx","./src/pages/projects/ProjectsPage.tsx","./src/services/api/assets.api.ts","./src/services/api/client.ts","./src/services/api/crm.api.ts","./src/services/api/projects.api.ts","./src/stores/useAuthStore.ts"],"version":"5.9.3"}
|
||||
2
vite.config.d.ts
vendored
Normal file
2
vite.config.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
24
vite.config.js
Normal file
24
vite.config.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
25
vite.config.ts
Normal file
25
vite.config.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user