Reapply "Build post chat and attachment workflows"
This reverts commit 5bc4a551af.
This commit is contained in:
@@ -9,6 +9,7 @@ server {
|
||||
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;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' blob: data:; connect-src 'self' http://localhost:8081 http://127.0.0.1:8081; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -10,21 +10,63 @@ 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 { clearStoredToken, getStoredToken, loadCurrentUser, logout, refreshSession, type AuthUser } from "@/lib/auth";
|
||||
import { listClients, type Client } from "@/lib/clients";
|
||||
|
||||
const APP_STATE_STORAGE_KEY = "mira_app_state";
|
||||
const validViews = new Set(["dashboard", "clients", "client-dashboard", "post-detail", "calendar", "users"]);
|
||||
|
||||
type StoredAppState = {
|
||||
currentView: string;
|
||||
selectedClientId: string | null;
|
||||
selectedPostId: string | null;
|
||||
};
|
||||
|
||||
function readStoredAppState(): StoredAppState {
|
||||
const fallback = {
|
||||
currentView: "dashboard",
|
||||
selectedClientId: null,
|
||||
selectedPostId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(APP_STATE_STORAGE_KEY);
|
||||
if (!raw) return fallback;
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<StoredAppState>;
|
||||
const currentView = typeof parsed.currentView === "string" && validViews.has(parsed.currentView)
|
||||
? parsed.currentView
|
||||
: fallback.currentView;
|
||||
const selectedClientId = typeof parsed.selectedClientId === "string" ? parsed.selectedClientId : null;
|
||||
const selectedPostId = typeof parsed.selectedPostId === "string" ? parsed.selectedPostId : null;
|
||||
|
||||
if (currentView === "client-dashboard" && !selectedClientId) return fallback;
|
||||
if (currentView === "post-detail" && !selectedPostId) return fallback;
|
||||
|
||||
return {
|
||||
currentView,
|
||||
selectedClientId,
|
||||
selectedPostId,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const storedAppState = readStoredAppState();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
|
||||
const [selectedClientId, setSelectedClientId] = useState<string | null>(storedAppState.selectedClientId);
|
||||
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 [currentView, setCurrentView] = useState(storedAppState.currentView);
|
||||
const [selectedPostId, setSelectedPostId] = useState<string | null>(storedAppState.selectedPostId);
|
||||
const invitationToken =
|
||||
window.location.pathname === "/accept-invitation"
|
||||
? new URLSearchParams(window.location.search).get("token")
|
||||
: null;
|
||||
const isClientViewer = user?.role === "client_viewer";
|
||||
|
||||
async function refreshClients(token = getStoredToken()) {
|
||||
if (!token) return;
|
||||
@@ -45,7 +87,16 @@ export default function App() {
|
||||
const token = getStoredToken();
|
||||
|
||||
if (!token) {
|
||||
setIsCheckingSession(false);
|
||||
refreshSession()
|
||||
.then((response) => {
|
||||
setUser(response.user);
|
||||
return refreshClients(response.access_token);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => setIsCheckingSession(false));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,14 +106,51 @@ export default function App() {
|
||||
return refreshClients(token);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
return refreshSession()
|
||||
.then((response) => {
|
||||
setUser(response.user);
|
||||
return refreshClients(response.access_token);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
});
|
||||
})
|
||||
.finally(() => setIsCheckingSession(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(
|
||||
APP_STATE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
currentView,
|
||||
selectedClientId,
|
||||
selectedPostId,
|
||||
}),
|
||||
);
|
||||
}, [currentView, selectedClientId, selectedPostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === "client-dashboard" && !selectedClientId) {
|
||||
setCurrentView("clients");
|
||||
}
|
||||
if (currentView === "post-detail" && !selectedPostId) {
|
||||
setCurrentView("calendar");
|
||||
}
|
||||
}, [currentView, selectedClientId, selectedPostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClientViewer) return;
|
||||
|
||||
setSelectedClientId((current) => current ?? clients[0]?.id ?? null);
|
||||
if (currentView === "clients" || currentView === "users" || currentView === "client-dashboard") {
|
||||
setCurrentView("dashboard");
|
||||
}
|
||||
}, [clients, currentView, isClientViewer]);
|
||||
|
||||
function handleLogout() {
|
||||
clearStoredToken();
|
||||
void logout();
|
||||
localStorage.removeItem(APP_STATE_STORAGE_KEY);
|
||||
setUser(null);
|
||||
setClients([]);
|
||||
setSelectedClientId(null);
|
||||
@@ -75,9 +163,9 @@ export default function App() {
|
||||
void refreshClients();
|
||||
}
|
||||
|
||||
function handleInvitationAccepted(loadedUser: AuthUser) {
|
||||
setUser(loadedUser);
|
||||
void refreshClients();
|
||||
function handleInvitationAccepted() {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
function handleClientCreated(client: Client) {
|
||||
@@ -166,9 +254,14 @@ export default function App() {
|
||||
<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} />
|
||||
<DashboardView
|
||||
selectedClientId={isClientViewer ? selectedClientId : null}
|
||||
canCreatePost={!isClientViewer}
|
||||
onViewChange={setCurrentView}
|
||||
onPostSelect={handlePostSelect}
|
||||
/>
|
||||
)}
|
||||
{currentView === "clients" && (
|
||||
{!isClientViewer && currentView === "clients" && (
|
||||
<ClientsView
|
||||
clients={clients}
|
||||
onClientCreated={handleClientCreated}
|
||||
@@ -177,7 +270,7 @@ export default function App() {
|
||||
onClientSelect={setSelectedClientId}
|
||||
/>
|
||||
)}
|
||||
{currentView === "client-dashboard" && selectedClientId && (
|
||||
{!isClientViewer && currentView === "client-dashboard" && selectedClientId && (
|
||||
<ClientDashboardView
|
||||
client={clients.find((client) => client.id === selectedClientId) ?? null}
|
||||
selectedClientId={selectedClientId}
|
||||
@@ -186,12 +279,12 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
{currentView === "post-detail" && selectedPostId && (
|
||||
<PostDetailView itemId={selectedPostId} onBack={() => setCurrentView("calendar")} />
|
||||
<PostDetailView itemId={selectedPostId} onBack={() => setCurrentView("calendar")} user={user} />
|
||||
)}
|
||||
{currentView === "calendar" && (
|
||||
<CalendarView clients={clients} selectedClientId={selectedClientId} user={user} onPostSelect={handlePostSelect} />
|
||||
)}
|
||||
{currentView === "users" && <UsersView clients={clients} />}
|
||||
{!isClientViewer && currentView === "users" && <UsersView clients={clients} />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,12 +21,18 @@ type NavProps = {
|
||||
|
||||
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 isClientViewer = user.role === "client_viewer";
|
||||
const navItems = isClientViewer
|
||||
? [
|
||||
{ id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard },
|
||||
{ id: "calendar", label: "Calendário", icon: CalendarDays },
|
||||
]
|
||||
: [
|
||||
{ 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();
|
||||
@@ -43,20 +49,24 @@ export function Sidebar({ currentView, onViewChange, user, onLogout, clients, se
|
||||
<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)}
|
||||
/>
|
||||
{!isClientViewer && (
|
||||
<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>
|
||||
</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>
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">
|
||||
{isClientViewer ? "Cliente" : "Agência"}
|
||||
</div>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentView === item.id;
|
||||
@@ -77,6 +87,7 @@ export function Sidebar({ currentView, onViewChange, user, onLogout, clients, se
|
||||
);
|
||||
})}
|
||||
|
||||
{!isClientViewer && (
|
||||
<div className="pt-5">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Dashboards</div>
|
||||
{clientsError && (
|
||||
@@ -110,6 +121,7 @@ export function Sidebar({ currentView, onViewChange, user, onLogout, clients, se
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border/40 space-y-4">
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
@@ -40,6 +41,7 @@ export async function apiFormRequest<T>(path: string, formData: FormData, token?
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
@@ -16,20 +16,35 @@ type LoginResponse = {
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
type AcceptInvitationResponse = {
|
||||
message: string;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export function getStoredToken() {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function storeToken(token: string) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
|
||||
export function clearStoredToken() {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
sessionStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await apiRequest<{ message: string }>("/auth/logout", {
|
||||
method: "POST",
|
||||
}).catch(() => undefined);
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
sessionStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
@@ -43,11 +58,19 @@ export async function login(email: string, password: string) {
|
||||
}
|
||||
|
||||
export async function acceptInvitation(token: string, name: string, password: string) {
|
||||
const response = await apiRequest<LoginResponse>("/auth/invitations/accept", {
|
||||
const response = await apiRequest<AcceptInvitationResponse>("/auth/invitations/accept", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, name, password }),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function refreshSession() {
|
||||
const response = await apiRequest<LoginResponse>("/auth/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
storeToken(response.access_token);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_BASE_URL, apiFormRequest, apiRequest } from "@/lib/api";
|
||||
import { API_BASE_URL, ApiError, apiFormRequest, apiRequest } from "@/lib/api";
|
||||
|
||||
export type Holiday = {
|
||||
id: string;
|
||||
@@ -51,6 +51,34 @@ export type Attachment = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ChatChannel = "external" | "internal";
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
calendar_item_id: string;
|
||||
channel: ChatChannel;
|
||||
body: string;
|
||||
created_by: string | null;
|
||||
author_name: string;
|
||||
author_role: string;
|
||||
created_at: string;
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
attachments: ChatAttachment[] | null;
|
||||
};
|
||||
|
||||
export type ChatAttachment = {
|
||||
id: string;
|
||||
chat_message_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;
|
||||
@@ -120,6 +148,18 @@ type AttachmentResponse = {
|
||||
attachment: Attachment;
|
||||
};
|
||||
|
||||
type ChatAttachmentResponse = {
|
||||
attachment: ChatAttachment;
|
||||
};
|
||||
|
||||
type ChatMessagesResponse = {
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
type ChatMessageResponse = {
|
||||
message: ChatMessage;
|
||||
};
|
||||
|
||||
export async function listHolidays(token: string, year: number) {
|
||||
const response = await apiRequest<HolidaysResponse>(`/calendar/holidays?year=${year}`, {
|
||||
method: "GET",
|
||||
@@ -251,6 +291,99 @@ export async function uploadAttachment(token: string, itemId: string, file: File
|
||||
return response.attachment;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(token: string, attachmentId: string) {
|
||||
const response = await apiRequest<AttachmentResponse>(`/calendar/attachments/${attachmentId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.attachment;
|
||||
}
|
||||
|
||||
export async function listChatMessages(token: string, itemId: string, channel: ChatChannel) {
|
||||
const params = new URLSearchParams({ channel });
|
||||
const response = await apiRequest<ChatMessagesResponse>(`/calendar/items/${itemId}/chat?${params.toString()}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.messages;
|
||||
}
|
||||
|
||||
export async function createChatMessage(token: string, itemId: string, channel: ChatChannel, body: string) {
|
||||
const response = await apiRequest<ChatMessageResponse>(`/calendar/items/${itemId}/chat`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({ channel, body }),
|
||||
});
|
||||
|
||||
return response.message;
|
||||
}
|
||||
|
||||
export async function updateChatMessage(token: string, messageId: string, body: string) {
|
||||
const response = await apiRequest<ChatMessageResponse>(`/calendar/chat/messages/${messageId}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
|
||||
return response.message;
|
||||
}
|
||||
|
||||
export async function deleteChatMessage(token: string, messageId: string) {
|
||||
const response = await apiRequest<ChatMessageResponse>(`/calendar/chat/messages/${messageId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.message;
|
||||
}
|
||||
|
||||
export async function uploadChatAttachment(token: string, messageId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
|
||||
const response = await apiFormRequest<ChatAttachmentResponse>(`/calendar/chat/messages/${messageId}/attachments`, formData, token);
|
||||
return response.attachment;
|
||||
}
|
||||
|
||||
export async function downloadAttachmentBlob(token: string, attachmentId: string) {
|
||||
return downloadProtectedBlob(token, attachmentDownloadURL(attachmentId));
|
||||
}
|
||||
|
||||
export async function downloadChatAttachmentBlob(token: string, attachmentId: string) {
|
||||
return downloadProtectedBlob(token, chatAttachmentDownloadURL(attachmentId));
|
||||
}
|
||||
|
||||
async function downloadProtectedBlob(token: string, url: string) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data
|
||||
? String(data.message)
|
||||
: "Nao foi possivel baixar o anexo.";
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export function attachmentDownloadURL(attachmentId: string) {
|
||||
return `${API_BASE_URL}/calendar/attachments/${attachmentId}/download`;
|
||||
}
|
||||
|
||||
export function chatAttachmentDownloadURL(attachmentId: string) {
|
||||
return `${API_BASE_URL}/calendar/chat/attachments/${attachmentId}/download`;
|
||||
}
|
||||
|
||||
export function chatStreamURL(itemId: string, channel: ChatChannel) {
|
||||
const params = new URLSearchParams({ channel });
|
||||
return `${API_BASE_URL}/calendar/items/${itemId}/chat/stream?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type Invitation = {
|
||||
accepted_at: string | null;
|
||||
created_at: string;
|
||||
invitation_url?: string;
|
||||
email_sent: boolean;
|
||||
email_error?: string;
|
||||
};
|
||||
|
||||
type UsersResponse = {
|
||||
|
||||
@@ -3,11 +3,11 @@ 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";
|
||||
import { acceptInvitation } from "@/lib/auth";
|
||||
|
||||
type AcceptInvitationViewProps = {
|
||||
token: string;
|
||||
onAccepted: (user: AuthUser) => void;
|
||||
onAccepted: () => void;
|
||||
};
|
||||
|
||||
export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationViewProps) {
|
||||
@@ -22,9 +22,10 @@ export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationView
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await acceptInvitation(token, name, password);
|
||||
onAccepted(response.user);
|
||||
await acceptInvitation(token, name, password);
|
||||
onAccepted();
|
||||
window.history.replaceState({}, "", "/");
|
||||
window.location.assign("/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível aceitar o convite.");
|
||||
} finally {
|
||||
@@ -77,7 +78,7 @@ export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationView
|
||||
)}
|
||||
|
||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Ativando..." : "Entrar no Mira"}
|
||||
{isSubmitting ? "Ativando..." : "Criar senha"}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -50,6 +50,7 @@ type CalendarEvent = {
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat("pt-BR", { month: "long", year: "numeric" });
|
||||
const dateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
const attachmentAcceptTypes = "image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.doc,.docx";
|
||||
const statusLabels: Record<CalendarItem["status"], string> = {
|
||||
rascunho: "Rascunho",
|
||||
planejado: "Planejado",
|
||||
@@ -923,7 +924,7 @@ export function CalendarView({ clients, selectedClientId, user, onPostSelect }:
|
||||
<form className="space-y-4 border-t border-border/40 pt-4" onSubmit={handleUploadAttachment}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="attachment">Anexos</Label>
|
||||
<Input id="attachment" name="attachment" type="file" accept="image/jpeg,image/png,image/webp,application/pdf" />
|
||||
<Input id="attachment" name="attachment" type="file" accept={attachmentAcceptTypes} />
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
|
||||
@@ -20,6 +20,7 @@ type DashboardViewProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
compact?: boolean;
|
||||
canCreatePost?: boolean;
|
||||
onViewChange: (view: string) => void;
|
||||
onPostSelect: (postId: string) => void;
|
||||
};
|
||||
@@ -41,6 +42,7 @@ export function DashboardView({
|
||||
title = "Dashboard Semanal",
|
||||
description,
|
||||
compact = false,
|
||||
canCreatePost = true,
|
||||
onViewChange,
|
||||
onPostSelect,
|
||||
}: DashboardViewProps) {
|
||||
@@ -119,10 +121,12 @@ export function DashboardView({
|
||||
<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>
|
||||
{canCreatePost && (
|
||||
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ export function UsersView({ clients }: UsersViewProps) {
|
||||
const [role, setRole] = useState<Invitation["role"]>("agency_user");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [createdURL, setCreatedURL] = useState("");
|
||||
const [inviteStatus, setInviteStatus] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -59,6 +60,7 @@ export function UsersView({ clients }: UsersViewProps) {
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
setCreatedURL("");
|
||||
setInviteStatus("");
|
||||
try {
|
||||
const invitation = await createInvitation(token, {
|
||||
email,
|
||||
@@ -69,6 +71,11 @@ export function UsersView({ clients }: UsersViewProps) {
|
||||
setEmail("");
|
||||
setClientId("");
|
||||
setCreatedURL(invitation.invitation_url ?? "");
|
||||
setInviteStatus(
|
||||
invitation.email_sent
|
||||
? "Convite criado e e-mail enviado."
|
||||
: invitation.email_error || "Convite criado. Configure o SMTP para enviar por e-mail ou copie o link abaixo.",
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível criar o convite.");
|
||||
} finally {
|
||||
@@ -144,6 +151,12 @@ export function UsersView({ clients }: UsersViewProps) {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{inviteStatus && (
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
|
||||
{inviteStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user