first commit
This commit is contained in:
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f97316" />
|
||||
<title>Mira</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/metadata.json
Normal file
6
frontend/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "",
|
||||
"description": "",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
22
frontend/nginx.conf
Normal file
22
frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
7720
frontend/package-lock.json
generated
Normal file
7720
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "mira-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=5173 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.1",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"shadcn": "^4.10.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3"
|
||||
}
|
||||
}
|
||||
1
frontend/public/.gitkeep
Normal file
1
frontend/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
200
frontend/src/App.tsx
Normal file
200
frontend/src/App.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { LoginView } from "./views/LoginView";
|
||||
import { AcceptInvitationView } from "./views/AcceptInvitationView";
|
||||
import { DashboardView } from "./views/DashboardView";
|
||||
import { CalendarView } from "./views/CalendarView";
|
||||
import { UsersView } from "./views/UsersView";
|
||||
import { ClientsView } from "./views/ClientsView";
|
||||
import { ClientDashboardView } from "./views/ClientDashboardView";
|
||||
import { PostDetailView } from "./views/PostDetailView";
|
||||
import { Topbar } from "./components/layout/Topbar";
|
||||
import { Sidebar } from "./components/layout/Sidebar";
|
||||
import { clearStoredToken, getStoredToken, loadCurrentUser, type AuthUser } from "@/lib/auth";
|
||||
import { listClients, type Client } from "@/lib/clients";
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||
const [clientsError, setClientsError] = useState("");
|
||||
const [currentView, setCurrentView] = useState("dashboard"); // 'dashboard' | 'calendar' | 'users'
|
||||
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
|
||||
const invitationToken =
|
||||
window.location.pathname === "/accept-invitation"
|
||||
? new URLSearchParams(window.location.search).get("token")
|
||||
: null;
|
||||
|
||||
async function refreshClients(token = getStoredToken()) {
|
||||
if (!token) return;
|
||||
try {
|
||||
setClientsError("");
|
||||
const loadedClients = await listClients(token);
|
||||
setClients(loadedClients);
|
||||
setSelectedClientId((current) => {
|
||||
if (!current) return null;
|
||||
return loadedClients.some((client) => client.id === current) ? current : null;
|
||||
});
|
||||
} catch (err) {
|
||||
setClientsError(err instanceof Error ? err.message : "Nao foi possivel carregar clientes.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
|
||||
if (!token) {
|
||||
setIsCheckingSession(false);
|
||||
return;
|
||||
}
|
||||
|
||||
loadCurrentUser(token)
|
||||
.then((loadedUser) => {
|
||||
setUser(loadedUser);
|
||||
return refreshClients(token);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => setIsCheckingSession(false));
|
||||
}, []);
|
||||
|
||||
function handleLogout() {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
setClients([]);
|
||||
setSelectedClientId(null);
|
||||
setCurrentView("dashboard");
|
||||
setSelectedPostId(null);
|
||||
}
|
||||
|
||||
function handleLogin(loadedUser: AuthUser) {
|
||||
setUser(loadedUser);
|
||||
void refreshClients();
|
||||
}
|
||||
|
||||
function handleInvitationAccepted(loadedUser: AuthUser) {
|
||||
setUser(loadedUser);
|
||||
void refreshClients();
|
||||
}
|
||||
|
||||
function handleClientCreated(client: Client) {
|
||||
setClients((current) => [...current, client].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setSelectedClientId(client.id);
|
||||
}
|
||||
|
||||
function handleClientUpdated(client: Client) {
|
||||
setClients((current) => current.map((item) => (item.id === client.id ? client : item)).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}
|
||||
|
||||
function handleClientDeleted(clientId: string) {
|
||||
setClients((current) => current.filter((client) => client.id !== clientId));
|
||||
setSelectedClientId((current) => (current === clientId ? null : current));
|
||||
setCurrentView((current) => (current === "client-dashboard" && selectedClientId === clientId ? "clients" : current));
|
||||
}
|
||||
|
||||
function handleClientDashboardSelect(clientId: string) {
|
||||
setSelectedClientId(clientId);
|
||||
setCurrentView("client-dashboard");
|
||||
}
|
||||
|
||||
function handlePostSelect(postId: string) {
|
||||
setSelectedPostId(postId);
|
||||
setCurrentView("post-detail");
|
||||
}
|
||||
|
||||
if (isCheckingSession) {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">Carregando sessao...</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (invitationToken) {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<AcceptInvitationView token={invitationToken} onAccepted={handleInvitationAccepted} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<LoginView onLogin={handleLogin} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col md:flex-row">
|
||||
{/* Mobile Topbar for responsive */}
|
||||
<div className="md:hidden">
|
||||
<Topbar
|
||||
onViewChange={setCurrentView}
|
||||
currentView={currentView}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
clients={clients}
|
||||
selectedClientId={selectedClientId}
|
||||
onClientSelect={handleClientDashboardSelect}
|
||||
clientsError={clientsError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden md:flex sticky top-0 h-screen">
|
||||
<Sidebar
|
||||
onViewChange={setCurrentView}
|
||||
currentView={currentView}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
clients={clients}
|
||||
selectedClientId={selectedClientId}
|
||||
onClientSelect={handleClientDashboardSelect}
|
||||
clientsError={clientsError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-x-hidden">
|
||||
<main className="p-4 md:p-8 max-w-7xl mx-auto w-full">
|
||||
{currentView === "dashboard" && (
|
||||
<DashboardView onViewChange={setCurrentView} onPostSelect={handlePostSelect} />
|
||||
)}
|
||||
{currentView === "clients" && (
|
||||
<ClientsView
|
||||
clients={clients}
|
||||
onClientCreated={handleClientCreated}
|
||||
onClientUpdated={handleClientUpdated}
|
||||
onClientDeleted={handleClientDeleted}
|
||||
onClientSelect={setSelectedClientId}
|
||||
/>
|
||||
)}
|
||||
{currentView === "client-dashboard" && selectedClientId && (
|
||||
<ClientDashboardView
|
||||
client={clients.find((client) => client.id === selectedClientId) ?? null}
|
||||
selectedClientId={selectedClientId}
|
||||
onViewChange={setCurrentView}
|
||||
onPostSelect={handlePostSelect}
|
||||
/>
|
||||
)}
|
||||
{currentView === "post-detail" && selectedPostId && (
|
||||
<PostDetailView itemId={selectedPostId} onBack={() => setCurrentView("calendar")} />
|
||||
)}
|
||||
{currentView === "calendar" && (
|
||||
<CalendarView clients={clients} selectedClientId={selectedClientId} user={user} onPostSelect={handlePostSelect} />
|
||||
)}
|
||||
{currentView === "users" && <UsersView clients={clients} />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
153
frontend/src/components/layout/Sidebar.tsx
Normal file
153
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { LayoutDashboard, CalendarDays, Users, Settings, LogOut, Search, Building2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
import type { Client } from "@/lib/clients";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type NavProps = {
|
||||
currentView: string;
|
||||
onViewChange: (target: string) => void;
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
clients: Client[];
|
||||
selectedClientId: string | null;
|
||||
onClientSelect: (clientId: string) => void;
|
||||
clientsError: string;
|
||||
};
|
||||
|
||||
export function Sidebar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
|
||||
const [clientSearch, setClientSearch] = useState("");
|
||||
const navItems = [
|
||||
{ id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard },
|
||||
{ id: "clients", label: "Clientes", icon: Building2 },
|
||||
{ id: "calendar", label: "Calendário", icon: CalendarDays },
|
||||
{ id: "users", label: "Pessoas", icon: Users },
|
||||
];
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
const term = clientSearch.trim().toLowerCase();
|
||||
if (!term) return clients;
|
||||
return clients.filter((client) => client.name.toLowerCase().includes(term));
|
||||
}, [clients, clientSearch]);
|
||||
|
||||
return (
|
||||
<aside className="w-64 border-r border-border/40 bg-card h-screen flex flex-col sticky top-0">
|
||||
<div className="h-16 flex items-center px-6 border-b border-border/40">
|
||||
<div className="h-8 w-8 bg-primary rounded-md flex items-center justify-center mr-3">
|
||||
<span className="text-primary-foreground font-bold leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-semibold text-lg tracking-tight">Mira</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar clientes..."
|
||||
className="pl-9 h-9 bg-muted/50 border-none shadow-none focus-visible:ring-1"
|
||||
value={clientSearch}
|
||||
onChange={(event) => setClientSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Agência</div>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentView === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onViewChange(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Dashboards</div>
|
||||
{clientsError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{clientsError}
|
||||
</div>
|
||||
)}
|
||||
{!clientsError && filteredClients.length === 0 && (
|
||||
<div className="rounded-md border border-dashed border-border/60 px-3 py-3 text-xs text-muted-foreground">
|
||||
{clients.length === 0 ? "Nenhum cliente cadastrado." : "Nenhum cliente encontrado."}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{filteredClients.map((client) => {
|
||||
const isSelected = currentView === "client-dashboard" && selectedClientId === client.id;
|
||||
return (
|
||||
<button
|
||||
key={client.id}
|
||||
onClick={() => onClientSelect(client.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="truncate">{client.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border/40 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ModeToggle />
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground h-9 w-9">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9 border border-border/50">
|
||||
<AvatarImage src="" />
|
||||
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-none truncate">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">{roleLabel(user.role)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={onLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function roleLabel(role: AuthUser["role"]) {
|
||||
if (role === "super_admin") return "Super admin";
|
||||
if (role === "agency_user") return "Agencia";
|
||||
return "Cliente";
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || "M";
|
||||
}
|
||||
74
frontend/src/components/layout/Topbar.tsx
Normal file
74
frontend/src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { LogOut, Menu } from "lucide-react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
import type { Client } from "@/lib/clients";
|
||||
|
||||
type NavProps = {
|
||||
currentView: string;
|
||||
onViewChange: (target: string) => void;
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
clients: Client[];
|
||||
selectedClientId: string | null;
|
||||
onClientSelect: (clientId: string) => void;
|
||||
clientsError: string;
|
||||
};
|
||||
|
||||
export function Topbar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
|
||||
return (
|
||||
<header className="h-14 border-b border-border/40 bg-card flex items-center justify-between px-4 sticky top-0 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger className={buttonVariants({ variant: "ghost", size: "icon", className: "-ml-2 h-9 w-9" })}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle Menu</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-72">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Sidebar
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
clients={clients}
|
||||
selectedClientId={selectedClientId}
|
||||
onClientSelect={onClientSelect}
|
||||
clientsError={clientsError}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-[10px] leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-semibold text-base tracking-tight">Mira</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8 border border-border/50">
|
||||
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={onLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || "M";
|
||||
}
|
||||
35
frontend/src/components/mode-toggle.tsx
Normal file
35
frontend/src/components/mode-toggle.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={buttonVariants({ variant: "ghost", size: "icon" })}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Claro
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Escuro
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
Sistema
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
73
frontend/src/components/theme-provider.tsx
Normal file
73
frontend/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
109
frontend/src/components/ui/avatar.tsx
Normal file
109
frontend/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
frontend/src/components/ui/badge.tsx
Normal file
52
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
219
frontend/src/components/ui/calendar.tsx
Normal file
219
frontend/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
type Locale,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString(locale?.code, { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-(--cell-radius)",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] text-muted-foreground select-none",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_end
|
||||
),
|
||||
today: cn(
|
||||
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: ({ ...props }) => (
|
||||
<CalendarDayButton locale={locale} {...props} />
|
||||
),
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
103
frontend/src/components/ui/card.tsx
Normal file
103
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
27
frontend/src/components/ui/checkbox.tsx
Normal file
27
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
20
frontend/src/components/ui/input.tsx
Normal file
20
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
frontend/src/components/ui/label.tsx
Normal file
20
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
88
frontend/src/components/ui/popover.tsx
Normal file
88
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
55
frontend/src/components/ui/scroll-area.tsx
Normal file
55
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
201
frontend/src/components/ui/select.tsx
Normal file
201
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
138
frontend/src/components/ui/sheet.tsx
Normal file
138
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
50
frontend/src/components/ui/sonner.tsx
Normal file
50
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
import type { CSSProperties } from "react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
80
frontend/src/components/ui/tabs.tsx
Normal file
80
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
138
frontend/src/index.css
Normal file
138
frontend/src/index.css
Normal file
@@ -0,0 +1,138 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.98 0.005 60); /* Off-white */
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.65 0.22 45); /* Orange */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.15 0.005 45); /* Near black/charcoal */
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.20 0.006 45);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.20 0.006 45);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.65 0.22 45); /* Orange */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.22 45);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
a[href],
|
||||
select,
|
||||
input[type="checkbox"],
|
||||
input[type="file"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
65
frontend/src/lib/api.ts
Normal file
65
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1";
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
if (options.token) {
|
||||
headers.set("Authorization", `Bearer ${options.token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data
|
||||
? String(data.message)
|
||||
: "Nao foi possivel concluir a solicitacao.";
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function apiFormRequest<T>(path: string, formData: FormData, token?: string | null): Promise<T> {
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data
|
||||
? String(data.message)
|
||||
: "Nao foi possivel concluir a solicitacao.";
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
62
frontend/src/lib/auth.ts
Normal file
62
frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { apiRequest } from "@/lib/api";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "mira_access_token";
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "super_admin" | "agency_user" | "client_viewer";
|
||||
status: string;
|
||||
};
|
||||
|
||||
type LoginResponse = {
|
||||
access_token: string;
|
||||
token_type: "Bearer";
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export function getStoredToken() {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function storeToken(token: string) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
|
||||
export function clearStoredToken() {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const response = await apiRequest<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
storeToken(response.access_token);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(token: string, name: string, password: string) {
|
||||
const response = await apiRequest<LoginResponse>("/auth/invitations/accept", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, name, password }),
|
||||
});
|
||||
|
||||
storeToken(response.access_token);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function loadCurrentUser(token: string) {
|
||||
const response = await apiRequest<MeResponse>("/me", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.user;
|
||||
}
|
||||
256
frontend/src/lib/calendar.ts
Normal file
256
frontend/src/lib/calendar.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { API_BASE_URL, apiFormRequest, apiRequest } from "@/lib/api";
|
||||
|
||||
export type Holiday = {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
type: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type CustomDate = {
|
||||
id: string;
|
||||
client_id: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
date: string;
|
||||
recurs_annually: boolean;
|
||||
visibility: "agency" | "client";
|
||||
category: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CalendarItem = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content_type: string;
|
||||
status: "rascunho" | "planejado" | "em_producao" | "em_revisao" | "aprovado" | "publicado" | "cancelado";
|
||||
owner_id: string | null;
|
||||
scheduled_date: string;
|
||||
scheduled_at: string | null;
|
||||
copy_text: string;
|
||||
internal_notes: string;
|
||||
client_notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
calendar_item_id: string;
|
||||
original_filename: string;
|
||||
stored_filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
storage_driver: string;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreateCustomDateInput = {
|
||||
client_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
recurs_annually: boolean;
|
||||
visibility: "agency" | "client";
|
||||
category: string;
|
||||
};
|
||||
|
||||
export type UpdateCustomDateInput = {
|
||||
client_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
recurs_annually: boolean;
|
||||
visibility: "agency" | "client";
|
||||
};
|
||||
|
||||
export type CreateCalendarItemInput = {
|
||||
client_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
content_type: string;
|
||||
status: CalendarItem["status"];
|
||||
scheduled_date: string;
|
||||
copy_text?: string;
|
||||
internal_notes?: string;
|
||||
client_notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateCalendarItemInput = Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
status: CalendarItem["status"];
|
||||
scheduled_date: string;
|
||||
copy_text: string;
|
||||
internal_notes: string;
|
||||
client_notes: string;
|
||||
}>;
|
||||
|
||||
type HolidaysResponse = {
|
||||
holidays: Holiday[];
|
||||
};
|
||||
|
||||
type CustomDatesResponse = {
|
||||
custom_dates: CustomDate[];
|
||||
};
|
||||
|
||||
type CustomDateResponse = {
|
||||
custom_date: CustomDate;
|
||||
};
|
||||
|
||||
type CalendarItemsResponse = {
|
||||
items: CalendarItem[];
|
||||
};
|
||||
|
||||
type CalendarItemResponse = {
|
||||
item: CalendarItem;
|
||||
};
|
||||
|
||||
type AttachmentsResponse = {
|
||||
attachments: Attachment[];
|
||||
};
|
||||
|
||||
type AttachmentResponse = {
|
||||
attachment: Attachment;
|
||||
};
|
||||
|
||||
export async function listHolidays(token: string, year: number) {
|
||||
const response = await apiRequest<HolidaysResponse>(`/calendar/holidays?year=${year}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.holidays;
|
||||
}
|
||||
|
||||
export async function listCustomDates(token: string, year: number, clientId?: string | null) {
|
||||
const params = new URLSearchParams({ year: String(year) });
|
||||
if (clientId) {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
const response = await apiRequest<CustomDatesResponse>(`/calendar/custom-dates?${params.toString()}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.custom_dates;
|
||||
}
|
||||
|
||||
export async function createCustomDate(token: string, input: CreateCustomDateInput) {
|
||||
const response = await apiRequest<CustomDateResponse>("/calendar/custom-dates", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.custom_date;
|
||||
}
|
||||
|
||||
export async function updateCustomDate(token: string, customDateId: string, input: UpdateCustomDateInput) {
|
||||
const response = await apiRequest<CustomDateResponse>(`/calendar/custom-dates/${customDateId}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.custom_date;
|
||||
}
|
||||
|
||||
export async function deleteCustomDate(token: string, customDateId: string) {
|
||||
await apiRequest<{ message: string }>(`/calendar/custom-dates/${customDateId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCalendarItems(token: string, year: number, clientId?: string | null) {
|
||||
const params = new URLSearchParams({ year: String(year) });
|
||||
if (clientId) {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function listCalendarItemsByRange(token: string, from: string, to: string, clientId?: string | null) {
|
||||
const params = new URLSearchParams({ from, to });
|
||||
if (clientId) {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function createCalendarItem(token: string, input: CreateCalendarItemInput) {
|
||||
const response = await apiRequest<CalendarItemResponse>("/calendar/items", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function getCalendarItem(token: string, itemId: string) {
|
||||
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function updateCalendarItem(token: string, itemId: string, input: UpdateCalendarItemInput) {
|
||||
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function cancelCalendarItem(token: string, itemId: string) {
|
||||
await apiRequest<{ message: string }>(`/calendar/items/${itemId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAttachments(token: string, itemId: string) {
|
||||
const response = await apiRequest<AttachmentsResponse>(`/calendar/items/${itemId}/attachments`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.attachments;
|
||||
}
|
||||
|
||||
export async function uploadAttachment(token: string, itemId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
|
||||
const response = await apiFormRequest<AttachmentResponse>(`/calendar/items/${itemId}/attachments`, formData, token);
|
||||
return response.attachment;
|
||||
}
|
||||
|
||||
export function attachmentDownloadURL(attachmentId: string) {
|
||||
return `${API_BASE_URL}/calendar/attachments/${attachmentId}/download`;
|
||||
}
|
||||
79
frontend/src/lib/clients.ts
Normal file
79
frontend/src/lib/clients.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { apiRequest } from "@/lib/api";
|
||||
|
||||
export type Client = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: "active" | "archived";
|
||||
website_url: string;
|
||||
instagram_url: string;
|
||||
facebook_url: string;
|
||||
linkedin_url: string;
|
||||
color: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type ClientsResponse = {
|
||||
clients: Client[];
|
||||
};
|
||||
|
||||
type ClientResponse = {
|
||||
client: Client;
|
||||
};
|
||||
|
||||
export async function listClients(token: string) {
|
||||
const response = await apiRequest<ClientsResponse>("/clients", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.clients;
|
||||
}
|
||||
|
||||
export async function createClient(token: string, name: string) {
|
||||
const response = await apiRequest<ClientResponse>("/clients", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
return response.client;
|
||||
}
|
||||
|
||||
export async function updateClient(token: string, client: Client) {
|
||||
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
name: client.name,
|
||||
status: client.status,
|
||||
website_url: client.website_url,
|
||||
instagram_url: client.instagram_url,
|
||||
facebook_url: client.facebook_url,
|
||||
linkedin_url: client.linkedin_url,
|
||||
color: client.color,
|
||||
notes: client.notes,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.client;
|
||||
}
|
||||
|
||||
export async function archiveClient(token: string, client: Client) {
|
||||
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify({ ...client, status: "archived" }),
|
||||
});
|
||||
|
||||
return response.client;
|
||||
}
|
||||
|
||||
export async function deleteClient(token: string, clientId: string) {
|
||||
await apiRequest<{ message: string }>(`/clients/${clientId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
54
frontend/src/lib/users.ts
Normal file
54
frontend/src/lib/users.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
|
||||
export type Invitation = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "agency_user" | "client_viewer";
|
||||
client_id: string | null;
|
||||
client_name: string | null;
|
||||
expires_at: string;
|
||||
accepted_at: string | null;
|
||||
created_at: string;
|
||||
invitation_url?: string;
|
||||
};
|
||||
|
||||
type UsersResponse = {
|
||||
users: AuthUser[];
|
||||
};
|
||||
|
||||
type InvitationsResponse = {
|
||||
invitations: Invitation[];
|
||||
};
|
||||
|
||||
type InvitationResponse = {
|
||||
invitation: Invitation;
|
||||
};
|
||||
|
||||
export async function listUsers(token: string) {
|
||||
const response = await apiRequest<UsersResponse>("/users", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.users;
|
||||
}
|
||||
|
||||
export async function listInvitations(token: string) {
|
||||
const response = await apiRequest<InvitationsResponse>("/users/invitations", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.invitations;
|
||||
}
|
||||
|
||||
export async function createInvitation(token: string, input: { email: string; role: Invitation["role"]; client_id?: string }) {
|
||||
const response = await apiRequest<InvitationResponse>("/users/invitations", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.invitation;
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
87
frontend/src/views/AcceptInvitationView.tsx
Normal file
87
frontend/src/views/AcceptInvitationView.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ArrowRight, CalendarDays } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { acceptInvitation, type AuthUser } from "@/lib/auth";
|
||||
|
||||
type AcceptInvitationViewProps = {
|
||||
token: string;
|
||||
onAccepted: (user: AuthUser) => void;
|
||||
};
|
||||
|
||||
export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationViewProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await acceptInvitation(token, name, password);
|
||||
onAccepted(response.user);
|
||||
window.history.replaceState({}, "", "/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível aceitar o convite.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary text-primary-foreground flex items-center justify-center mx-auto">
|
||||
<CalendarDays className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Aceitar convite</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Crie sua senha para acessar o Mira.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-name">Nome</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Seu nome"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-password">Senha</Label>
|
||||
<Input
|
||||
id="invite-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Mínimo de 12 caracteres"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Ativando..." : "Entrar no Mira"}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1164
frontend/src/views/CalendarView.tsx
Normal file
1164
frontend/src/views/CalendarView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
249
frontend/src/views/ClientDashboardView.tsx
Normal file
249
frontend/src/views/ClientDashboardView.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertCircle, CalendarDays, CheckCircle2, Clock, ExternalLink, FileText, Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import {
|
||||
listCalendarItemsByRange,
|
||||
listCustomDates,
|
||||
listHolidays,
|
||||
type CalendarItem,
|
||||
type CustomDate,
|
||||
type Holiday,
|
||||
} from "@/lib/calendar";
|
||||
import type { Client } from "@/lib/clients";
|
||||
|
||||
type ClientDashboardViewProps = {
|
||||
client: Client | null;
|
||||
selectedClientId: string;
|
||||
onViewChange: (view: string) => void;
|
||||
onPostSelect: (postId: string) => void;
|
||||
};
|
||||
|
||||
const statusLabels: Record<CalendarItem["status"], string> = {
|
||||
rascunho: "Rascunho",
|
||||
planejado: "Planejado",
|
||||
em_producao: "Em produção",
|
||||
em_revisao: "Em revisão",
|
||||
aprovado: "Aprovado",
|
||||
publicado: "Publicado",
|
||||
cancelado: "Cancelado",
|
||||
};
|
||||
|
||||
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
|
||||
const longDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
|
||||
export function ClientDashboardView({ client, selectedClientId, onViewChange, onPostSelect }: ClientDashboardViewProps) {
|
||||
const today = useMemo(() => new Date(), []);
|
||||
const monthStart = useMemo(() => new Date(today.getFullYear(), today.getMonth(), 1), [today]);
|
||||
const monthEnd = useMemo(() => new Date(today.getFullYear(), today.getMonth() + 1, 0), [today]);
|
||||
const [items, setItems] = useState<CalendarItem[]>([]);
|
||||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const monthStartValue = formatDateValue(monthStart);
|
||||
const monthEndValue = formatDateValue(monthEnd);
|
||||
const todayValue = formatDateValue(today);
|
||||
const year = today.getFullYear();
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
Promise.all([
|
||||
listCalendarItemsByRange(token, monthStartValue, monthEndValue, selectedClientId),
|
||||
listHolidays(token, year),
|
||||
listCustomDates(token, year, selectedClientId),
|
||||
])
|
||||
.then(([loadedItems, loadedHolidays, loadedCustomDates]) => {
|
||||
setItems(loadedItems);
|
||||
setHolidays(loadedHolidays);
|
||||
setCustomDates(loadedCustomDates);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard do cliente.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [monthEndValue, monthStartValue, selectedClientId, year]);
|
||||
|
||||
const visibleItems = items
|
||||
.filter((item) => item.status !== "cancelado")
|
||||
.sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date));
|
||||
const upcomingItems = visibleItems.filter((item) => item.scheduled_date >= todayValue);
|
||||
const reviewItems = visibleItems.filter((item) => item.status === "em_revisao");
|
||||
const approvedItems = visibleItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
|
||||
const dates = [...holidays, ...customDates]
|
||||
.filter((date) => date.date >= monthStartValue && date.date <= monthEndValue)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
if (!client) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
Cliente não encontrado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard de {client.name}</h1>
|
||||
<Badge variant="outline">{client.status === "archived" ? "Arquivado" : "Ativo"}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Visão do cliente entre {shortDateFormatter.format(monthStart)} e {shortDateFormatter.format(monthEnd)}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
Calendário
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo Post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<MetricCard title="Posts do mês" value={visibleItems.length} detail={`${upcomingItems.length} próximos`} />
|
||||
<MetricCard title="Em aprovação" value={reviewItems.length} detail="Aguardando retorno" />
|
||||
<MetricCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
|
||||
<MetricCard title="Datas do mês" value={dates.length} detail="Feriados e datas úteis" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
Linha do tempo de posts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground py-6">Carregando posts...</p>}
|
||||
{!isLoading && visibleItems.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-6">Nenhum post previsto para este mês.</p>
|
||||
)}
|
||||
{visibleItems.map((item) => (
|
||||
<button key={item.id} type="button" className="w-full rounded-lg border border-border/60 bg-card p-4 text-left hover:bg-muted/30" onClick={() => onPostSelect(item.id)}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300">
|
||||
{statusLabels[item.status]}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{longDateFormatter.format(parseDateValue(item.scheduled_date))}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-medium text-sm text-foreground">{item.title}</h2>
|
||||
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
|
||||
{item.client_notes && <p className="text-sm text-muted-foreground">{item.client_notes}</p>}
|
||||
</div>
|
||||
{item.status === "aprovado" || item.status === "publicado" ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-primary" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 shrink-0 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
Datas relevantes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 space-y-3">
|
||||
{dates.map((date) => (
|
||||
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{date.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"source" in date ? "Feriado nacional" : date.category === "feriados-brasil" ? "Data comemorativa" : "Data da agência"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono bg-muted/40">
|
||||
{shortDateFormatter.format(parseDateValue(date.date))}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{dates.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4">Nenhuma data relevante neste mês.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(client.website_url || client.instagram_url || client.facebook_url || client.linkedin_url) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base">Links do cliente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 space-y-2">
|
||||
<ClientLink label="Website" url={client.website_url} />
|
||||
<ClientLink label="Instagram" url={client.instagram_url} />
|
||||
<ClientLink label="Facebook" url={client.facebook_url} />
|
||||
<ClientLink label="LinkedIn" url={client.linkedin_url} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientLink({ label, url }: { label: string; url: string }) {
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<a className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30" href={url} target="_blank" rel="noreferrer">
|
||||
<span>{label}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateValue(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseDateValue(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
345
frontend/src/views/ClientsView.tsx
Normal file
345
frontend/src/views/ClientsView.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { Archive, Building2, ChevronDown, ChevronUp, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { archiveClient, createClient, deleteClient, updateClient, type Client } from "@/lib/clients";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
|
||||
type ClientsViewProps = {
|
||||
clients: Client[];
|
||||
onClientCreated: (client: Client) => void;
|
||||
onClientUpdated: (client: Client) => void;
|
||||
onClientDeleted: (clientId: string) => void;
|
||||
onClientSelect: (clientId: string) => void;
|
||||
};
|
||||
|
||||
export function ClientsView({ clients, onClientCreated, onClientUpdated, onClientDeleted, onClientSelect }: ClientsViewProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [editing, setEditing] = useState<Record<string, Client>>({});
|
||||
const [expandedClients, setExpandedClients] = useState<Record<string, boolean>>({});
|
||||
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [busyClientId, setBusyClientId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const colorOptions = ["#f97316", "#06b6d4", "#22c55e", "#a855f7", "#ef4444", "#eab308", "#14b8a6", "#64748b"];
|
||||
|
||||
async function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const client = await createClient(token, name);
|
||||
onClientCreated(client);
|
||||
setName("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel criar o cliente.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function draftFor(client: Client) {
|
||||
return editing[client.id] ?? client;
|
||||
}
|
||||
|
||||
function updateDraft(client: Client, field: keyof Client, value: string) {
|
||||
setEditing((current) => ({
|
||||
...current,
|
||||
[client.id]: { ...draftFor(client), [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave(client: Client) {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setBusyClientId(client.id);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateClient(token, draftFor(client));
|
||||
onClientUpdated(updated);
|
||||
setEditing((current) => {
|
||||
const copy = { ...current };
|
||||
delete copy[client.id];
|
||||
return copy;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel atualizar o cliente.");
|
||||
} finally {
|
||||
setBusyClientId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive(client: Client) {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setBusyClientId(client.id);
|
||||
setError("");
|
||||
try {
|
||||
const archived = await archiveClient(token, draftFor(client));
|
||||
onClientUpdated(archived);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel arquivar o cliente.");
|
||||
} finally {
|
||||
setBusyClientId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteClient() {
|
||||
const token = getStoredToken();
|
||||
if (!token || !clientToDelete || deleteConfirmation !== clientToDelete.name) return;
|
||||
|
||||
setBusyClientId(clientToDelete.id);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await deleteClient(token, clientToDelete.id);
|
||||
onClientDeleted(clientToDelete.id);
|
||||
setClientToDelete(null);
|
||||
setDeleteConfirmation("");
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : "Nao foi possivel excluir o cliente.");
|
||||
} finally {
|
||||
setBusyClientId("");
|
||||
}
|
||||
}
|
||||
|
||||
function viewAsClient(client: Client) {
|
||||
onClientSelect(client.id);
|
||||
setExpandedClients((current) => ({ ...current, [client.id]: true }));
|
||||
}
|
||||
|
||||
function toggleClient(client: Client) {
|
||||
onClientSelect(client.id);
|
||||
setExpandedClients((current) => ({ ...current, [client.id]: !current[client.id] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Clientes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Cadastre e organize os clientes atendidos pela agência.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Novo cliente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col sm:flex-row gap-3" onSubmit={handleCreate}>
|
||||
<div className="grid gap-2 flex-1">
|
||||
<Label htmlFor="client-name">Nome do cliente</Label>
|
||||
<Input id="client-name" placeholder="Ex.: TechCorp" value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</div>
|
||||
<Button className="sm:self-end" type="submit" disabled={isSubmitting}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isSubmitting ? "Criando..." : "Criar cliente"}
|
||||
</Button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{clients.length === 0 && (
|
||||
<div className="flex items-center justify-center h-[260px] border border-dashed border-border rounded-xl">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
|
||||
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Nenhum cliente cadastrado</h3>
|
||||
<p className="text-sm text-muted-foreground w-[300px]">Crie o primeiro cliente para começar a montar calendários e posts.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clients.map((client) => {
|
||||
const draft = draftFor(client);
|
||||
const isArchived = client.status === "archived";
|
||||
const isExpanded = expandedClients[client.id] ?? false;
|
||||
return (
|
||||
<Card key={client.id} className={isArchived ? "opacity-70" : ""}>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: client.color || "#f97316" }} />
|
||||
<h3 className="font-medium truncate">{client.name}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">/{client.slug}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={isArchived ? "outline" : "secondary"}>{isArchived ? "Arquivado" : "Ativo"}</Badge>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => toggleClient(client)}>
|
||||
{isExpanded ? <ChevronUp className="mr-2 h-4 w-4" /> : <ChevronDown className="mr-2 h-4 w-4" />}
|
||||
{isExpanded ? "Minimizar" : "Detalhes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="Nome" value={draft.name} onChange={(value) => updateDraft(client, "name", value)} />
|
||||
<Field label="Website" value={draft.website_url} onChange={(value) => updateDraft(client, "website_url", value)} />
|
||||
<Field label="Instagram" value={draft.instagram_url} onChange={(value) => updateDraft(client, "instagram_url", value)} />
|
||||
<Field label="Facebook" value={draft.facebook_url} onChange={(value) => updateDraft(client, "facebook_url", value)} />
|
||||
<Field label="LinkedIn" value={draft.linkedin_url} onChange={(value) => updateDraft(client, "linkedin_url", value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Cor do cliente</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
aria-label={`Selecionar cor ${color}`}
|
||||
className={`h-8 w-8 rounded-full border-2 transition ${draft.color === color ? "border-foreground" : "border-transparent"}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => updateDraft(client, "color", color)}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
className="h-9 w-[120px] font-mono"
|
||||
value={draft.color}
|
||||
onChange={(event) => updateDraft(client, "color", event.target.value)}
|
||||
pattern="^#[0-9a-fA-F]{6}$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Notas</Label>
|
||||
<Textarea value={draft.notes} onChange={(event) => updateDraft(client, "notes", event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" onClick={() => handleSave(client)} disabled={busyClientId === client.id}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Salvar
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => viewAsClient(client)}>
|
||||
Atualizar dashboard
|
||||
</Button>
|
||||
{!isArchived && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => handleArchive(client)} disabled={busyClientId === client.id}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Arquivar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setClientToDelete(client);
|
||||
setDeleteConfirmation("");
|
||||
setDeleteError("");
|
||||
}}
|
||||
disabled={busyClientId === client.id}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={clientToDelete !== null} onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setClientToDelete(null);
|
||||
setDeleteConfirmation("");
|
||||
setDeleteError("");
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir cliente</DialogTitle>
|
||||
<DialogDescription>
|
||||
Esta ação exclui permanentemente o cliente, seus vínculos, posts e datas personalizadas. Para confirmar, digite o nome do cliente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{clientToDelete && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">Nome do cliente: </span>
|
||||
<span className="font-medium">{clientToDelete.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="delete-client-confirmation">Digite o nome do cliente</Label>
|
||||
<Input
|
||||
id="delete-client-confirmation"
|
||||
value={deleteConfirmation}
|
||||
onChange={(event) => setDeleteConfirmation(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setClientToDelete(null);
|
||||
setDeleteConfirmation("");
|
||||
setDeleteError("");
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deleteConfirmation !== clientToDelete.name || busyClientId === clientToDelete.id}
|
||||
onClick={handleDeleteClient}
|
||||
>
|
||||
{busyClientId === clientToDelete.id ? "Excluindo..." : "Excluir definitivamente"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<Input value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
344
frontend/src/views/DashboardView.tsx
Normal file
344
frontend/src/views/DashboardView.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertCircle, Calendar as CalendarIcon, CheckCircle2, Clock, FileImage, Filter, Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import {
|
||||
listCalendarItemsByRange,
|
||||
listCustomDates,
|
||||
listHolidays,
|
||||
type CalendarItem,
|
||||
type CustomDate,
|
||||
type Holiday,
|
||||
} from "@/lib/calendar";
|
||||
|
||||
type DashboardViewProps = {
|
||||
selectedClientId?: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
compact?: boolean;
|
||||
onViewChange: (view: string) => void;
|
||||
onPostSelect: (postId: string) => void;
|
||||
};
|
||||
|
||||
const statusLabels: Record<CalendarItem["status"], string> = {
|
||||
rascunho: "Rascunho",
|
||||
planejado: "Planejado",
|
||||
em_producao: "Em produção",
|
||||
em_revisao: "Em revisão",
|
||||
aprovado: "Aprovado",
|
||||
publicado: "Publicado",
|
||||
cancelado: "Cancelado",
|
||||
};
|
||||
|
||||
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
|
||||
|
||||
export function DashboardView({
|
||||
selectedClientId = null,
|
||||
title = "Dashboard Semanal",
|
||||
description,
|
||||
compact = false,
|
||||
onViewChange,
|
||||
onPostSelect,
|
||||
}: DashboardViewProps) {
|
||||
const today = useMemo(() => new Date(), []);
|
||||
const weekStart = useMemo(() => startOfWeek(today), [today]);
|
||||
const weekEnd = useMemo(() => addDays(weekStart, 6), [weekStart]);
|
||||
const [items, setItems] = useState<CalendarItem[]>([]);
|
||||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | CalendarItem["status"]>("all");
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
|
||||
const todayValue = formatDateValue(today);
|
||||
const weekStartValue = formatDateValue(weekStart);
|
||||
const weekEndValue = formatDateValue(weekEnd);
|
||||
const years = useMemo(() => yearsInRange(weekStart, weekEnd), [weekEnd, weekStart]);
|
||||
const yearsKey = years.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
Promise.all([
|
||||
listCalendarItemsByRange(token, weekStartValue, weekEndValue, selectedClientId),
|
||||
Promise.all(years.map((year) => listHolidays(token, year))),
|
||||
Promise.all(years.map((year) => listCustomDates(token, year, selectedClientId))),
|
||||
])
|
||||
.then(([loadedItems, loadedHolidaysByYear, loadedCustomDatesByYear]) => {
|
||||
setItems(loadedItems);
|
||||
setHolidays(loadedHolidaysByYear.flat());
|
||||
setCustomDates(loadedCustomDatesByYear.flat());
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [selectedClientId, weekEndValue, weekStartValue, yearsKey]);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
const matchesStatus = statusFilter === "all" || item.status === statusFilter;
|
||||
const term = searchFilter.trim().toLowerCase();
|
||||
const matchesSearch =
|
||||
term === "" ||
|
||||
item.title.toLowerCase().includes(term) ||
|
||||
item.client_name.toLowerCase().includes(term);
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
const todayItems = filteredItems.filter((item) => item.scheduled_date === todayValue);
|
||||
const reviewItems = filteredItems.filter((item) => item.status === "em_revisao");
|
||||
const approvedItems = filteredItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
|
||||
const pendingItems = filteredItems.filter((item) => !["aprovado", "publicado", "cancelado"].includes(item.status));
|
||||
const weekDates = [...holidays, ...customDates]
|
||||
.filter((date) => date.date >= weekStartValue && date.date <= weekEndValue)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const warningDate = weekDates[0];
|
||||
const periodDescription =
|
||||
description ??
|
||||
`Visão geral entre ${shortDateFormatter.format(weekStart)} e ${shortDateFormatter.format(weekEnd)}.`;
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className={compact ? "text-lg font-semibold tracking-tight" : "text-2xl font-semibold tracking-tight"}>{title}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{periodDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setShowFilters((current) => !current)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo Post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<div className="grid gap-3 rounded-lg border border-border/60 bg-card p-4 sm:grid-cols-[180px_1fr]">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "all" | CalendarItem["status"])}
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Buscar</label>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
|
||||
placeholder="Título ou cliente"
|
||||
value={searchFilter}
|
||||
onChange={(event) => setSearchFilter(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<SummaryCard title="Para Hoje" value={todayItems.length} detail={`${pendingItems.length} pendentes na semana`} />
|
||||
<SummaryCard title="Em Aprovação" value={reviewItems.length} detail="Aguardando revisão ou cliente" />
|
||||
<SummaryCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
|
||||
<Card className="shadow-sm bg-muted/40 border-dashed border-2">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Avisos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||
{warningDate ? warningDate.name : "Sem datas críticas"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{warningDate ? shortDateFormatter.format(parseDateValue(warningDate.date)) : "Nenhum feriado ou data na semana"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Tabs defaultValue="pending" className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="bg-transparent border-b border-border/40 w-full justify-start h-auto p-0 rounded-none">
|
||||
<TabsTrigger value="pending" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
|
||||
Ações Pendentes
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 rounded-sm">{pendingItems.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="approved" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
|
||||
Aprovados
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="pending" className="pt-4 space-y-3 m-0">
|
||||
<PostList items={pendingItems} isLoading={isLoading} emptyText="Nenhuma ação pendente nesta semana." onPostSelect={onPostSelect} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="approved" className="pt-4 space-y-3 m-0">
|
||||
<PostList items={approvedItems} isLoading={isLoading} emptyText="Nenhum post aprovado nesta semana." onPostSelect={onPostSelect} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
Datas da Semana
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-3 space-y-3">
|
||||
{weekDates.map((date) => (
|
||||
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between text-sm">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-foreground">{date.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{"source" in date ? "Nacional" : "Personalizada"}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono bg-muted/40">
|
||||
{shortDateFormatter.format(parseDateValue(date.date))}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{weekDates.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-3">Nenhuma data comemorativa nesta semana.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button variant="ghost" className="w-full text-xs h-8 text-primary" onClick={() => onViewChange("calendar")}>
|
||||
Ver calendário completo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PostList({
|
||||
items,
|
||||
isLoading,
|
||||
emptyText,
|
||||
onPostSelect,
|
||||
}: {
|
||||
items: CalendarItem[];
|
||||
isLoading: boolean;
|
||||
emptyText: string;
|
||||
onPostSelect: (postId: string) => void;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground py-6">Carregando postagens...</p>;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground py-6">{emptyText}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((post) => (
|
||||
<button key={post.id} type="button" className="flex w-full flex-col sm:flex-row gap-4 p-4 rounded-lg border border-border/60 bg-card hover:bg-muted/30 transition-colors shadow-sm text-left" onClick={() => onPostSelect(post.id)}>
|
||||
<div className="flex-1 space-y-1 z-10">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="text-[10px] uppercase font-semibold text-muted-foreground">{post.client_name}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20">
|
||||
{post.content_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm text-foreground line-clamp-1">{post.title}</h3>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground pt-1">
|
||||
<span className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" /> {shortDateFormatter.format(parseDateValue(post.scheduled_date))}</span>
|
||||
<span className="flex items-center gap-1.5"><FileImage className="h-3.5 w-3.5" /> Anexos pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex sm:flex-col items-center sm:items-end justify-between sm:justify-center border-t sm:border-t-0 sm:border-l border-border/40 pt-3 sm:pt-0 sm:pl-4 mt-2 sm:mt-0 gap-2">
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-300 flex items-center gap-1.5">
|
||||
{post.status === "aprovado" || post.status === "publicado" ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertCircle className="h-3.5 w-3.5" />}
|
||||
{statusLabels[post.status]}
|
||||
</span>
|
||||
<Avatar className="h-6 w-6 border-border border">
|
||||
<AvatarFallback className="text-[10px] bg-primary/5 text-primary font-medium">{initials(post.client_name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function startOfWeek(date: Date) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const day = copy.getDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
return addDays(copy, mondayOffset);
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function formatDateValue(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseDateValue(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function yearsInRange(from: Date, to: Date) {
|
||||
const years: number[] = [];
|
||||
for (let year = from.getFullYear(); year <= to.getFullYear(); year += 1) {
|
||||
years.push(year);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
function initials(value: string) {
|
||||
return value
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
118
frontend/src/views/LoginView.tsx
Normal file
118
frontend/src/views/LoginView.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { login, type AuthUser } from "@/lib/auth";
|
||||
|
||||
export function LoginView({ onLogin }: { onLogin: (user: AuthUser) => void }) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await login(email, password);
|
||||
onLogin(response.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel fazer login.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40 flex items-center justify-center p-4 relative">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="flex justify-center flex-col items-center gap-2 mb-8">
|
||||
<div className="h-12 w-12 bg-primary rounded-xl flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-2xl tracking-tighter">M</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Mira</h1>
|
||||
<p className="text-sm text-muted-foreground">Planejamento Editorial & Operações</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm border-border/50">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-xl">Acessar a Plataforma</CardTitle>
|
||||
<CardDescription>
|
||||
Insira suas credenciais corporativas ou do cliente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail corporativo</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="nome@agencia.com.br"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Sua senha de acesso"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal text-muted-foreground">Lembrar neste dispositivo</Label>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Entrando..." : "Entrar"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
Problemas com o acesso? Fale com o gestor de contas.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
frontend/src/views/PostDetailView.tsx
Normal file
287
frontend/src/views/PostDetailView.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { ArrowLeft, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import {
|
||||
attachmentDownloadURL,
|
||||
getCalendarItem,
|
||||
listAttachments,
|
||||
uploadAttachment,
|
||||
type Attachment,
|
||||
type CalendarItem,
|
||||
} from "@/lib/calendar";
|
||||
|
||||
type PostDetailViewProps = {
|
||||
itemId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
const statusLabels: Record<CalendarItem["status"], string> = {
|
||||
rascunho: "Rascunho",
|
||||
planejado: "Planejado",
|
||||
em_producao: "Em produção",
|
||||
em_revisao: "Em revisão",
|
||||
aprovado: "Aprovado",
|
||||
publicado: "Publicado",
|
||||
cancelado: "Cancelado",
|
||||
};
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
|
||||
export function PostDetailView({ itemId, onBack }: PostDetailViewProps) {
|
||||
const [item, setItem] = useState<CalendarItem | null>(null);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [attachmentError, setAttachmentError] = useState("");
|
||||
|
||||
const imageAttachments = useMemo(() => attachments.filter((attachment) => isImageAttachment(attachment)), [attachments]);
|
||||
const fileAttachments = useMemo(() => attachments.filter((attachment) => !isImageAttachment(attachment)), [attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
Promise.all([getCalendarItem(token, itemId), listAttachments(token, itemId)])
|
||||
.then(([loadedItem, loadedAttachments]) => {
|
||||
setItem(loadedItem);
|
||||
setAttachments(loadedAttachments);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar a postagem.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [itemId]);
|
||||
|
||||
async function handleUploadAttachment(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const token = getStoredToken();
|
||||
const fileInput = event.currentTarget.elements.namedItem("attachment") as HTMLInputElement | null;
|
||||
const file = fileInput?.files?.[0];
|
||||
if (!token || !file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setAttachmentError("");
|
||||
try {
|
||||
const attachment = await uploadAttachment(token, itemId, file);
|
||||
setAttachments((current) => [attachment, ...current]);
|
||||
if (fileInput) fileInput.value = "";
|
||||
} catch (err) {
|
||||
setAttachmentError(err instanceof Error ? err.message : "Não foi possível enviar o anexo.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && !item) {
|
||||
return <p className="text-sm text-muted-foreground">Carregando postagem...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{item.title}</h1>
|
||||
<Badge variant="secondary" className="bg-orange-500/10 text-orange-700 dark:text-orange-300">
|
||||
{statusLabels[item.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{item.client_name} · {dateFormatter.format(parseDateValue(item.scheduled_date))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Detalhes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ReadOnlyField label="Resumo" value={item.description} fallback="Sem resumo." />
|
||||
<ReadOnlyField label="Texto da postagem" value={item.copy_text} fallback="Sem texto cadastrado." multiline />
|
||||
<ReadOnlyField label="Notas para o cliente" value={item.client_notes} fallback="Sem notas para o cliente." multiline />
|
||||
<ReadOnlyField label="Notas internas" value={item.internal_notes} fallback="Sem notas internas." multiline />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anexos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="grid gap-3 sm:grid-cols-[1fr_auto]" onSubmit={handleUploadAttachment}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="post-detail-attachment">Enviar anexo</Label>
|
||||
<Input id="post-detail-attachment" name="attachment" type="file" accept="image/jpeg,image/png,image/webp,application/pdf" />
|
||||
</div>
|
||||
<Button className="sm:self-end" type="submit" disabled={isUploading}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isUploading ? "Enviando..." : "Enviar"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{attachmentError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{attachmentError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4">Nenhum anexo enviado.</p>
|
||||
)}
|
||||
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{imageAttachments.map((attachment) => (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
className="group overflow-hidden rounded-lg border border-border/60 bg-muted/20 text-left"
|
||||
onClick={() => setPreviewAttachment(attachment)}
|
||||
>
|
||||
<img
|
||||
src={attachmentDownloadURL(attachment.id)}
|
||||
alt={attachment.original_filename}
|
||||
className="aspect-square w-full object-cover transition group-hover:scale-[1.02]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 px-2 py-2 text-xs">
|
||||
<ImageIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{attachment.original_filename}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{fileAttachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachmentDownloadURL(attachment.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{attachment.original_filename}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{formatBytes(attachment.size_bytes)}
|
||||
<Download className="h-4 w-4" />
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Informações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<InfoRow label="Cliente" value={item.client_name} />
|
||||
<InfoRow label="Status" value={statusLabels[item.status]} />
|
||||
<InfoRow label="Tipo" value={item.content_type} />
|
||||
<InfoRow label="Data" value={dateFormatter.format(parseDateValue(item.scheduled_date))} />
|
||||
<InfoRow label="Anexos" value={String(attachments.length)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={previewAttachment !== null} onOpenChange={(open) => !open && setPreviewAttachment(null)}>
|
||||
<DialogContent className="sm:max-w-[860px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{previewAttachment?.original_filename}</DialogTitle>
|
||||
<DialogDescription>Visualização do anexo.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{previewAttachment && (
|
||||
<div className="overflow-hidden rounded-lg border border-border/60 bg-black/5">
|
||||
<img
|
||||
src={attachmentDownloadURL(previewAttachment.id)}
|
||||
alt={previewAttachment.original_filename}
|
||||
className="max-h-[70vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyField({ label, value, fallback, multiline = false }: { label: string; value: string; fallback: string; multiline?: boolean }) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
{multiline ? (
|
||||
<Textarea value={value || fallback} readOnly className="min-h-[110px]" />
|
||||
) : (
|
||||
<Input value={value || fallback} readOnly />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/40 pb-2 last:border-b-0 last:pb-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: Attachment) {
|
||||
return attachment.mime_type.startsWith("image/");
|
||||
}
|
||||
|
||||
function parseDateValue(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
226
frontend/src/views/UsersView.tsx
Normal file
226
frontend/src/views/UsersView.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Copy, Plus, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getStoredToken, type AuthUser } from "@/lib/auth";
|
||||
import { createInvitation, listInvitations, listUsers, type Invitation } from "@/lib/users";
|
||||
import type { Client } from "@/lib/clients";
|
||||
|
||||
type UsersViewProps = {
|
||||
clients: Client[];
|
||||
};
|
||||
|
||||
const roleLabels: Record<AuthUser["role"] | Invitation["role"], string> = {
|
||||
super_admin: "Super-admin",
|
||||
agency_user: "Agência",
|
||||
client_viewer: "Cliente",
|
||||
};
|
||||
|
||||
export function UsersView({ clients }: UsersViewProps) {
|
||||
const [users, setUsers] = useState<AuthUser[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<Invitation["role"]>("agency_user");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [createdURL, setCreatedURL] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
async function refresh() {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [loadedUsers, loadedInvitations] = await Promise.all([listUsers(token), listInvitations(token)]);
|
||||
setUsers(loadedUsers);
|
||||
setInvitations(loadedInvitations);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar acessos.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvite(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
setCreatedURL("");
|
||||
try {
|
||||
const invitation = await createInvitation(token, {
|
||||
email,
|
||||
role,
|
||||
client_id: role === "client_viewer" ? clientId : undefined,
|
||||
});
|
||||
setInvitations((current) => [invitation, ...current]);
|
||||
setEmail("");
|
||||
setClientId("");
|
||||
setCreatedURL(invitation.invitation_url ?? "");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível criar o convite.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvitationURL() {
|
||||
if (!createdURL) return;
|
||||
await navigator.clipboard.writeText(createdURL);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Gestão de Acessos</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Gerencie usuários da agência e clientes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Convidar usuário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3 md:grid-cols-[1fr_180px_220px_auto]" onSubmit={handleInvite}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-email">E-mail</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="nome@empresa.com.br"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-role">Perfil</Label>
|
||||
<select
|
||||
id="invite-role"
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as Invitation["role"])}
|
||||
>
|
||||
<option value="agency_user">Agência</option>
|
||||
<option value="client_viewer">Cliente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-client">Cliente</Label>
|
||||
<select
|
||||
id="invite-client"
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm disabled:opacity-60"
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
disabled={role !== "client_viewer"}
|
||||
required={role === "client_viewer"}
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
{clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>{client.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button className="md:self-end" type="submit" disabled={isSubmitting || (role === "client_viewer" && !clientId)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isSubmitting ? "Criando..." : "Convidar"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{createdURL && (
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Link do convite</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input value={createdURL} readOnly />
|
||||
<Button type="button" variant="outline" onClick={copyInvitationURL}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<AccessList title="Usuários ativos" emptyText="Nenhum usuário carregado." isLoading={isLoading}>
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-card p-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium truncate">{user.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{roleLabels[user.role]}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</AccessList>
|
||||
|
||||
<AccessList title="Convites" emptyText="Nenhum convite criado." isLoading={isLoading}>
|
||||
{invitations.map((invitation) => (
|
||||
<div key={invitation.id} className="rounded-lg border border-border/60 bg-card p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium truncate">{invitation.email}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{invitation.client_name ? `Cliente: ${invitation.client_name}` : "Acesso da agência"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={invitation.accepted_at ? "secondary" : "outline"}>
|
||||
{invitation.accepted_at ? "Aceito" : roleLabels[invitation.role]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expira em {new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium" }).format(new Date(invitation.expires_at))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</AccessList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessList({ title, emptyText, isLoading, children }: { title: string; emptyText: string; isLoading: boolean; children: ReactNode }) {
|
||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
{isLoading && <p className="text-sm text-muted-foreground py-4">Carregando...</p>}
|
||||
{!isLoading && !hasChildren && (
|
||||
<div className="flex items-center justify-center h-[220px] border border-dashed border-border rounded-xl">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
|
||||
<Users className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground w-[260px]">{emptyText}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && hasChildren && <div className="grid gap-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user