Initial commit

This commit is contained in:
rckrdmrd 2026-01-04 07:19:31 -06:00
commit 22001cb54c
57 changed files with 11801 additions and 0 deletions

24
.env.example Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

88
src/App.tsx Normal file
View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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
View 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,
};

View 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 };

View 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 };

View 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,
};

View 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 };

View 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
View 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,
};

View 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
View 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
View 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
View 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
View 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',
});
},
});
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}`),
};

View 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
View 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}`),
};

View 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}`),
};

View 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
View 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
View 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
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

1
tsconfig.tsbuildinfo Normal file
View 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
View File

@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

24
vite.config.js Normal file
View 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
View 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,
},
});