-
+
);
}
function TableHeader({className, ...props}: React.ComponentProps<"thead">) {
- return
;
+ return
;
}
function TableBody({className, ...props}: React.ComponentProps<"tbody">) {
@@ -28,26 +30,51 @@ function TableFooter({className, ...props}: React.ComponentProps<"tfoot">) {
);
}
-function TableRow({className, ...props}: React.ComponentProps<"tr">) {
+interface TableRowProps extends React.ComponentProps<"tr"> {
+ zebra?: boolean;
+}
+
+function TableRow({className, zebra, ...props}: TableRowProps) {
return (
|
);
}
-function TableHead({className, ...props}: React.ComponentProps<"th">) {
+interface TableHeadProps extends React.ComponentProps<"th"> {
+ sortable?: boolean;
+ onSort?: () => void;
+ sortDirection?: "asc" | "desc" | null;
+}
+
+function TableHead({className, sortable, onSort, sortDirection, children, ...props}: TableHeadProps) {
return (
[role=checkbox]]:translate-y-[2px]",
+ "text-foreground h-10 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+ sortable && "cursor-pointer select-none hover:bg-muted/50",
className
)}
+ onClick={sortable ? onSort : undefined}
{...props}
- />
+ >
+ {sortable ? (
+
+ ) : (
+ children
+ )}
+ |
);
}
@@ -56,7 +83,7 @@ function TableCell({className, ...props}: React.ComponentProps<"td">) {
[role=checkbox]]:translate-y-[2px]",
+ "px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
@@ -70,4 +97,90 @@ function TableCaption({className, ...props}: React.ComponentProps<"caption">) {
);
}
-export {Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption};
+// Loading skeleton for table
+function TableSkeleton({rows = 5, columns = 4}: {rows?: number; columns?: number}) {
+ return (
+
+
+
+ {Array.from({length: columns}).map((_, i) => (
+
+
+
+ ))}
+
+
+
+ {Array.from({length: rows}).map((_, rowIndex) => (
+
+ {Array.from({length: columns}).map((_, colIndex) => (
+
+
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+// Pagination component for tables
+interface TablePaginationProps {
+ currentPage: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ pageSize?: number;
+ totalItems?: number;
+}
+
+function TablePagination({currentPage, totalPages, onPageChange, pageSize, totalItems}: TablePaginationProps) {
+ return (
+
+
+ {totalItems && pageSize && (
+
+ Showing {Math.min((currentPage - 1) * pageSize + 1, totalItems)} to{" "}
+ {Math.min(currentPage * pageSize, totalItems)} of {totalItems} results
+
+ )}
+
+
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+ TableSkeleton,
+ TablePagination,
+};
diff --git a/mp_web_app/frontend/components/upload-file.tsx b/mp_web_app/frontend/components/upload-file.tsx
index 85a7829..d4de78c 100644
--- a/mp_web_app/frontend/components/upload-file.tsx
+++ b/mp_web_app/frontend/components/upload-file.tsx
@@ -5,6 +5,7 @@ import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/compo
import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {useNavigate} from "react-router-dom";
+import {useToast} from "@/components/ui/use-toast";
import apiClient from "@/context/apiClient";
type User = {
@@ -32,12 +33,12 @@ export default function UploadFile() {
[]
);
+ const {toast} = useToast();
const [fileName, setFileName] = useState("");
const [fileType, setFileType] = useState(fileTypeOptions[0]?.value ?? "");
const [file, setFile] = useState(null);
const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState("");
- const [success, setSuccess] = useState("");
+ const [isDragging, setIsDragging] = useState(false);
// Users state
const [users, setUsers] = useState([]);
@@ -45,7 +46,10 @@ export default function UploadFile() {
const [usersError, setUsersError] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState([]);
- // Frontend guard: only allow admins to access this page (keep your current logic if you prefer)
+ // Get user role
+ const [userRole, setUserRole] = useState("");
+
+ // Frontend guard: only allow admins and accountants to access this page
useEffect(() => {
try {
const token = localStorage.getItem("access_token");
@@ -59,9 +63,17 @@ export default function UploadFile() {
.replace(/_/g, "/")
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
const payload = JSON.parse(atob(base64));
- if (String(payload?.role ?? "").toUpperCase() !== "ADMIN") {
+ const role = String(payload?.role ?? "").toLowerCase();
+ setUserRole(role);
+
+ if (role !== "admin" && role !== "accountant") {
navigate("/");
}
+
+ // If accountant, set default file type to accounting
+ if (role === "accountant") {
+ setFileType("accounting");
+ }
} catch {
navigate("/login");
}
@@ -89,16 +101,22 @@ export default function UploadFile() {
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- setError("");
- setSuccess("");
const isPrivate = fileType === "private_documents";
if (!file || !fileName || !fileType) {
- setError("Моля, попълнете всички задължителни полета и изберете файл.");
+ toast({
+ title: "Грешка",
+ description: "Моля, попълнете всички задължителни полета и изберете файл.",
+ variant: "destructive",
+ });
return;
}
if (isPrivate && selectedUserIds.length === 0) {
- setError("При частни документи трябва да изберете поне един потребител.");
+ toast({
+ title: "Грешка",
+ description: "При частни документи трябва да изберете поне един потребител.",
+ variant: "destructive",
+ });
return;
}
@@ -115,18 +133,22 @@ export default function UploadFile() {
withCredentials: true,
});
- setSuccess("Файлът беше качен успешно.");
+ toast({
+ title: "Успех",
+ description: "Файлът беше качен успешно",
+ });
+
setFile(null);
setFileName("");
setFileType(fileTypeOptions[0]?.value ?? "");
setSelectedUserIds([]);
- setTimeout(() => {
- setSuccess("");
- navigate("/upload", {replace: true});
- }, 1200);
} catch (err: any) {
const msg = err?.response?.data?.detail || err?.response?.data?.message || err?.message || "Неуспех при качване.";
- setError(msg);
+ toast({
+ title: "Грешка",
+ description: msg,
+ variant: "destructive",
+ });
} finally {
setSubmitting(false);
}
@@ -134,9 +156,38 @@ export default function UploadFile() {
const isPrivate = fileType === "private_documents";
+ // Drag and drop handlers
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const files = e.dataTransfer.files;
+ if (files && files.length > 0) {
+ setFile(files[0]);
+ }
+ };
+
return (
-
-
+
+
Качи документ
@@ -147,15 +198,6 @@ export default function UploadFile() {
diff --git a/mp_web_app/frontend/context/AuthContext.tsx b/mp_web_app/frontend/context/AuthContext.tsx
index 5d147f6..873e132 100644
--- a/mp_web_app/frontend/context/AuthContext.tsx
+++ b/mp_web_app/frontend/context/AuthContext.tsx
@@ -3,9 +3,19 @@ import {useNavigate} from "react-router-dom";
import {API_BASE_URL} from "@/app-config";
import {getAccessToken, setAccessToken} from "@/context/tokenStore";
import apiClient from "@/context/apiClient";
+import {useQueryClient} from "@tanstack/react-query";
+
+interface User {
+ id: string;
+ email: string;
+ first_name?: string;
+ last_name?: string;
+ role?: string;
+}
interface AuthContextType {
isLoggedIn: boolean;
+ user: User | null;
login: (accessToken: string) => void;
logout: () => Promise ;
checkAuth: () => void;
@@ -20,7 +30,9 @@ function getInitialAuthState(): boolean {
export const AuthProvider = ({children}: {children: ReactNode}) => {
const [isLoggedIn, setIsLoggedIn] = useState(getInitialAuthState);
+ const [user, setUser] = useState(null);
const navigate = useNavigate();
+ const queryClient = useQueryClient();
// Function to check and update auth state (only checks token existence, no API calls)
const checkAuth = () => {
@@ -37,21 +49,21 @@ export const AuthProvider = ({children}: {children: ReactNode}) => {
const token = getAccessToken();
if (!token) {
setIsLoggedIn(false);
+ setUser(null);
return;
}
try {
- // Make a lightweight request to validate token
- // If token is expired, apiClient interceptor will automatically refresh it
- // Using news/get as a lightweight endpoint (returns quickly)
- await apiClient.get("news/list");
- // If we get here, token is valid or was refreshed successfully
+ // Fetch current user info
+ const response = await apiClient.get("users/me");
+ setUser(response.data);
setIsLoggedIn(true);
} catch (error: any) {
// If it's a 401 after refresh attempt, token is invalid
if (error.response?.status === 401) {
setAccessToken(null);
setIsLoggedIn(false);
+ setUser(null);
}
// For other errors (network, etc), keep logged in state
}
@@ -69,16 +81,35 @@ export const AuthProvider = ({children}: {children: ReactNode}) => {
// Listen for token cleared event from apiClient (when refresh fails)
const handleTokenCleared = () => {
setIsLoggedIn(false);
+ setUser(null);
+ // Invalidate all queries when logged out
+ queryClient.invalidateQueries();
+ };
+
+ // Listen for token refreshed event to invalidate cached queries
+ const handleTokenRefreshed = async () => {
+ // Fetch updated user info
+ try {
+ const response = await apiClient.get("users/me");
+ setUser(response.data);
+ setIsLoggedIn(true);
+ } catch {
+ // If fetching user fails, keep current state
+ }
+ // Invalidate all queries to refetch with new token
+ queryClient.invalidateQueries();
};
window.addEventListener("storage", handleStorage);
window.addEventListener("token-cleared", handleTokenCleared);
+ window.addEventListener("token-refreshed", handleTokenRefreshed);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener("token-cleared", handleTokenCleared);
+ window.removeEventListener("token-refreshed", handleTokenRefreshed);
};
- }, [isLoggedIn]);
+ }, [isLoggedIn, queryClient]);
const login = (accessToken: string) => {
setAccessToken(accessToken);
@@ -89,6 +120,7 @@ export const AuthProvider = ({children}: {children: ReactNode}) => {
// Clear access token first
setAccessToken(null);
setIsLoggedIn(false);
+ setUser(null);
try {
await fetch(`${API_BASE_URL}auth/logout`, {
@@ -102,7 +134,7 @@ export const AuthProvider = ({children}: {children: ReactNode}) => {
navigate("/");
};
- return {children};
+ return {children};
};
export function useAuth() {
diff --git a/mp_web_app/frontend/context/apiClient.ts b/mp_web_app/frontend/context/apiClient.ts
index 8efb3a5..4550863 100644
--- a/mp_web_app/frontend/context/apiClient.ts
+++ b/mp_web_app/frontend/context/apiClient.ts
@@ -38,6 +38,7 @@ apiClient.interceptors.response.use(
!original.url?.includes("/auth/refresh")
) {
(original as any)._retry = true;
+ (error as any).isAuthRefresh = true; // Mark as auto-handled auth error
// If already refreshing, wait for that refresh to complete
if (isRefreshing && refreshPromise) {
@@ -50,6 +51,8 @@ apiClient.interceptors.response.use(
}
} catch {
setAccessToken(null);
+ // Mark final error as auth-related
+ (error as any).isAuthRefresh = true;
return Promise.reject(error);
}
}
@@ -63,11 +66,13 @@ apiClient.interceptors.response.use(
const newToken = res.data?.access_token;
if (newToken) {
setAccessToken(newToken);
+ // Dispatch event to invalidate cached queries
+ window.dispatchEvent(new Event("token-refreshed"));
return newToken;
}
return null;
} catch (e) {
- // Silently handle refresh failures - don't log to console
+ // Silently handle refresh failures
setAccessToken(null);
window.dispatchEvent(tokenClearedEvent);
return null;
diff --git a/mp_web_app/frontend/hooks/useFiles.ts b/mp_web_app/frontend/hooks/useFiles.ts
new file mode 100644
index 0000000..8677266
--- /dev/null
+++ b/mp_web_app/frontend/hooks/useFiles.ts
@@ -0,0 +1,90 @@
+// hooks/useFiles.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import apiClient from '@/context/apiClient';
+
+export type FileType =
+ | 'governing_documents'
+ | 'forms'
+ | 'minutes'
+ | 'transcripts'
+ | 'accounting'
+ | 'private_documents'
+ | 'others';
+
+export interface FileMetadata {
+ id?: string | null;
+ file_name?: string | null;
+ file_type: FileType;
+ uploaded_by?: string | null;
+ created_at?: string | null;
+}
+
+// Query key factory
+export const fileKeys = {
+ all: ['files'] as const,
+ lists: () => [...fileKeys.all, 'list'] as const,
+ list: (fileType?: FileType) => [...fileKeys.lists(), { fileType }] as const,
+};
+
+// Fetch files by type
+export function useFiles(fileType: FileType) {
+ return useQuery({
+ queryKey: fileKeys.list(fileType),
+ queryFn: async () => {
+ const response = await apiClient.get('files/list', {
+ params: { file_type: fileType },
+ withCredentials: true,
+ });
+ return response.data ?? [];
+ },
+ staleTime: 60 * 1000, // 1 minute
+ });
+}
+
+// Fetch all files (admin)
+export function useAllFiles() {
+ return useQuery({
+ queryKey: fileKeys.lists(),
+ queryFn: async () => {
+ const fileTypes: FileType[] = [
+ 'governing_documents',
+ 'forms',
+ 'minutes',
+ 'transcripts',
+ 'accounting',
+ 'private_documents',
+ 'others',
+ ];
+
+ const allFiles: FileMetadata[] = [];
+ for (const type of fileTypes) {
+ try {
+ const response = await apiClient.get('files/list', {
+ params: { file_type: type },
+ });
+ if (response.data) {
+ allFiles.push(...response.data);
+ }
+ } catch (error) {
+ console.error(`Error fetching ${type}:`, error);
+ }
+ }
+ return allFiles;
+ },
+ staleTime: 5 * 60 * 1000, // 5 minutes (admin panel)
+ });
+}
+
+// Delete file mutation
+export function useDeleteFile() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await apiClient.delete(`files/delete/${id}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: fileKeys.all });
+ },
+ });
+}
diff --git a/mp_web_app/frontend/hooks/useGallery.ts b/mp_web_app/frontend/hooks/useGallery.ts
new file mode 100644
index 0000000..93f8d0a
--- /dev/null
+++ b/mp_web_app/frontend/hooks/useGallery.ts
@@ -0,0 +1,70 @@
+// hooks/useGallery.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import apiClient from '@/context/apiClient';
+import { API_BASE_URL } from '@/app-config';
+
+export interface GalleryImage {
+ id: string;
+ image_name: string;
+ s3_key: string;
+ s3_bucket: string;
+ uploaded_by: string;
+ created_at: string;
+ url?: string;
+}
+
+// Query key factory
+export const galleryKeys = {
+ all: ['gallery'] as const,
+ lists: () => [...galleryKeys.all, 'list'] as const,
+ list: () => [...galleryKeys.lists()] as const,
+};
+
+// Fetch gallery images
+export function useGallery() {
+ return useQuery({
+ queryKey: galleryKeys.list(),
+ queryFn: async () => {
+ const response = await fetch(`${API_BASE_URL}gallery/list`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch gallery');
+ }
+ const data = await response.json();
+ return (data || []) as GalleryImage[];
+ },
+ staleTime: 60 * 60 * 1000, // 1 hour (gallery rarely changes)
+ });
+}
+
+// Create gallery image mutation
+export function useCreateGalleryImage() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (formData: FormData) => {
+ const response = await apiClient.post('gallery/create', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: galleryKeys.list() });
+ },
+ });
+}
+
+// Delete gallery image mutation
+export function useDeleteGalleryImage() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await apiClient.delete(`gallery/delete/${id}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: galleryKeys.list() });
+ },
+ });
+}
diff --git a/mp_web_app/frontend/hooks/useMembers.ts b/mp_web_app/frontend/hooks/useMembers.ts
new file mode 100644
index 0000000..37fd384
--- /dev/null
+++ b/mp_web_app/frontend/hooks/useMembers.ts
@@ -0,0 +1,80 @@
+// hooks/useMembers.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import apiClient from '@/context/apiClient';
+
+export interface Member {
+ member_code: string;
+ first_name: string;
+ last_name: string;
+ email?: string;
+ phone?: string;
+ role?: string;
+ proxy?: boolean;
+ member_code_valid?: boolean;
+}
+
+// Query key factory
+export const memberKeys = {
+ all: ['members'] as const,
+ lists: () => [...memberKeys.all, 'list'] as const,
+ list: (filters?: { proxy_only?: boolean; role?: string }) =>
+ [...memberKeys.lists(), filters] as const,
+};
+
+// Fetch members list
+export function useMembers(filters?: { proxy_only?: boolean; role?: string }) {
+ return useQuery({
+ queryKey: memberKeys.list(filters),
+ queryFn: async () => {
+ const response = await apiClient.get('members/list', {
+ params: filters,
+ });
+ return response.data ?? [];
+ },
+ staleTime: 60 * 60 * 1000, // 1 hour
+ });
+}
+
+// Create member mutation
+export function useCreateMember() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (member: Omit) => {
+ const response = await apiClient.post('members/create', member);
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: memberKeys.lists() });
+ },
+ });
+}
+
+// Update member mutation
+export function useUpdateMember() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ member_code, ...member }: Member) => {
+ const response = await apiClient.put(`members/update/${member_code}`, member);
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: memberKeys.lists() });
+ },
+ });
+}
+
+// Delete member mutation
+export function useDeleteMember() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (member_code: string) => {
+ await apiClient.delete(`members/delete/${member_code}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: memberKeys.lists() });
+ },
+ });
+}
diff --git a/mp_web_app/frontend/hooks/useNews.ts b/mp_web_app/frontend/hooks/useNews.ts
new file mode 100644
index 0000000..38e4212
--- /dev/null
+++ b/mp_web_app/frontend/hooks/useNews.ts
@@ -0,0 +1,79 @@
+// hooks/useNews.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import apiClient from '@/context/apiClient';
+import { useAuth } from '@/context/AuthContext';
+
+export interface NewsItem {
+ id?: string | null;
+ title: string;
+ content: string;
+ author_id?: string | null;
+ created_at?: string | null;
+ news_type?: "regular" | "private";
+}
+
+// Query key factory
+export const newsKeys = {
+ all: ['news'] as const,
+ lists: () => [...newsKeys.all, 'list'] as const,
+ list: () => [...newsKeys.lists()] as const,
+};
+
+// Fetch news list
+export function useNews() {
+ const { isLoggedIn, user } = useAuth();
+
+ return useQuery({
+ queryKey: [...newsKeys.list(), { isLoggedIn, userId: user?.id }],
+ queryFn: async () => {
+ // Token is automatically sent via Authorization header by apiClient
+ const response = await apiClient.get('news/list');
+ return response.data ?? [];
+ },
+ staleTime: 5 * 60 * 1000, // 5 minutes (reduced from 10 to be more responsive)
+ });
+}
+
+// Create news mutation
+export function useCreateNews() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (news: Omit) => {
+ const response = await apiClient.post('news/create', news);
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: newsKeys.lists() });
+ },
+ });
+}
+
+// Update news mutation
+export function useUpdateNews() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, ...news }: NewsItem & { id: string }) => {
+ const response = await apiClient.put(`news/update/${id}`, news);
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: newsKeys.lists() });
+ },
+ });
+}
+
+// Delete news mutation
+export function useDeleteNews() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await apiClient.delete(`news/delete/${id}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: newsKeys.lists() });
+ },
+ });
+}
diff --git a/mp_web_app/frontend/hooks/useProducts.ts b/mp_web_app/frontend/hooks/useProducts.ts
new file mode 100644
index 0000000..fa768f9
--- /dev/null
+++ b/mp_web_app/frontend/hooks/useProducts.ts
@@ -0,0 +1,76 @@
+// hooks/useProducts.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import apiClient from '@/context/apiClient';
+
+export interface Product {
+ id?: string | null;
+ name: string;
+ width?: number | null;
+ height?: number | null;
+ length?: number | null;
+ description?: string | null;
+}
+
+// Query key factory
+export const productKeys = {
+ all: ['products'] as const,
+ lists: () => [...productKeys.all, 'list'] as const,
+ list: () => [...productKeys.lists()] as const,
+};
+
+// Fetch products list
+export function useProducts() {
+ return useQuery({
+ queryKey: productKeys.list(),
+ queryFn: async () => {
+ const response = await apiClient.get('products/list');
+ return response.data ?? [];
+ },
+ staleTime: 60 * 60 * 1000, // 1 hour (products rarely change)
+ });
+}
+
+// Create product mutation
+export function useCreateProduct() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (product: Omit) => {
+ const response = await apiClient.post('products/create', product);
+ return response.data;
+ },
+ onSuccess: () => {
+ // Invalidate products list to refetch
+ queryClient.invalidateQueries({ queryKey: productKeys.list() });
+ },
+ });
+}
+
+// Update product mutation
+export function useUpdateProduct() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, ...product }: Product & { id: string }) => {
+ const response = await apiClient.put(`products/update/${id}`, product);
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: productKeys.list() });
+ },
+ });
+}
+
+// Delete product mutation
+export function useDeleteProduct() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await apiClient.delete(`products/delete/${id}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: productKeys.list() });
+ },
+ });
+}
diff --git a/mp_web_app/frontend/hooks/useUsers.ts b/mp_web_app/frontend/hooks/useUsers.ts
new file mode 100644
index 0000000..71b9240
--- /dev/null
+++ b/mp_web_app/frontend/hooks/useUsers.ts
@@ -0,0 +1,89 @@
+// hooks/useUsers.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import apiClient from '@/context/apiClient';
+
+export interface User {
+ id?: string;
+ email: string;
+ first_name?: string;
+ last_name?: string;
+ phone?: string;
+ role?: string;
+ is_active?: boolean;
+ user_code?: string;
+ is_code_valid?: boolean;
+}
+
+// Query key factory
+export const userKeys = {
+ all: ['users'] as const,
+ lists: () => [...userKeys.all, 'list'] as const,
+ list: () => [...userKeys.lists()] as const,
+ board: () => [...userKeys.all, 'board'] as const,
+ control: () => [...userKeys.all, 'control'] as const,
+};
+
+// Fetch all users (admin)
+export function useUsersList() {
+ return useQuery({
+ queryKey: userKeys.list(),
+ queryFn: async () => {
+ const response = await apiClient.get('users/list');
+ return response.data ?? [];
+ },
+ staleTime: 5 * 60 * 1000, // 5 minutes (admin panel)
+ });
+}
+
+// Fetch board members
+export function useBoardMembers() {
+ return useQuery({
+ queryKey: userKeys.board(),
+ queryFn: async () => {
+ const response = await apiClient.get('users/board');
+ return response.data ?? [];
+ },
+ staleTime: 60 * 60 * 1000, // 1 hour
+ });
+}
+
+// Fetch control members
+export function useControlMembers() {
+ return useQuery({
+ queryKey: userKeys.control(),
+ queryFn: async () => {
+ const response = await apiClient.get('users/control');
+ return response.data ?? [];
+ },
+ staleTime: 60 * 60 * 1000, // 1 hour
+ });
+}
+
+// Update user mutation
+export function useUpdateUser() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, ...user }: User & { id: string }) => {
+ const response = await apiClient.put(`users/update/${id}`, user);
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: userKeys.all });
+ },
+ });
+}
+
+// Delete user mutation
+export function useDeleteUser() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await apiClient.delete(`users/delete/${id}`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: userKeys.all });
+ },
+ });
+}
diff --git a/mp_web_app/frontend/index.html b/mp_web_app/frontend/index.html
index 8fc8564..d945422 100644
--- a/mp_web_app/frontend/index.html
+++ b/mp_web_app/frontend/index.html
@@ -3,6 +3,9 @@
+
+
+
diff --git a/mp_web_app/frontend/lib/errorUtils.ts b/mp_web_app/frontend/lib/errorUtils.ts
index ddedf63..ebaced4 100644
--- a/mp_web_app/frontend/lib/errorUtils.ts
+++ b/mp_web_app/frontend/lib/errorUtils.ts
@@ -48,8 +48,17 @@ export function extractApiErrorDetails(error: any): string {
// File upload errors
if (/Invalid file type/i.test(msg)) return "Невалиден тип файл";
- if (/Invalid image format/i.test(msg)) return "Невалиден формат на изображение";
+ if (/Invalid image format/i.test(msg)) return "Невалиден формат на изображение. Разрешени формати: JPG, JPEG, PNG, GIF, WEBP";
if (/Invalid members list file type/i.test(msg)) return "Невалиден тип файл за списък с членове. Разрешен тип: .csv";
+ if (/File too large/i.test(msg)) {
+ // Extract size info if present
+ const match = msg.match(/Maximum size: (\d+)MB.*Your file: ([\d.]+)MB/i);
+ if (match) {
+ return `Файлът е твърде голям. Максимален размер: ${match[1]}MB. Вашият файл: ${match[2]}MB`;
+ }
+ return "Файлът е твърде голям";
+ }
+ if (/Image upload failed/i.test(msg)) return "Неуспешно качване на снимката";
// Token errors
if (/Invalid or expired token/i.test(msg)) return "Невалиден или изтекъл токен";
diff --git a/mp_web_app/frontend/lib/queryClient.ts b/mp_web_app/frontend/lib/queryClient.ts
new file mode 100644
index 0000000..abf5d36
--- /dev/null
+++ b/mp_web_app/frontend/lib/queryClient.ts
@@ -0,0 +1,19 @@
+// lib/queryClient.ts
+import { QueryClient } from '@tanstack/react-query';
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ // Stale time: how long data is considered fresh
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ // Cache time: how long unused data stays in cache
+ gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
+ // Retry failed requests
+ retry: 1,
+ // Refetch on window focus for fresh data
+ refetchOnWindowFocus: false,
+ // Refetch on reconnect
+ refetchOnReconnect: true,
+ },
+ },
+});
diff --git a/mp_web_app/frontend/package.json b/mp_web_app/frontend/package.json
index e612978..1a6a52e 100644
--- a/mp_web_app/frontend/package.json
+++ b/mp_web_app/frontend/package.json
@@ -25,6 +25,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
+ "@tanstack/react-query": "^5.90.9",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/mp_web_app/frontend/pages/Base.tsx b/mp_web_app/frontend/pages/Base.tsx
index 57cd81a..06579d1 100644
--- a/mp_web_app/frontend/pages/Base.tsx
+++ b/mp_web_app/frontend/pages/Base.tsx
@@ -1,22 +1,26 @@
-import {Outlet} from "react-router-dom";
-import {Logo} from "@/components/logo";
+import { Outlet } from "react-router-dom";
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import Navigation from "@/pages/Navigation";
export default function Base() {
return (
- {/* Example: header or logo can go here */}
-
+ {/* Modern Header with hero image and parallax effect */}
+
- {/* This is where child routes render */}
+ {/* Navigation bar - sticky on desktop, hamburger on mobile */}
+
+
+
+
+ {/* Main content area - child routes render here */}
-
+ {/* Modern Footer with company information */}
+
);
}
diff --git a/mp_web_app/frontend/pages/Gallery.tsx b/mp_web_app/frontend/pages/Gallery.tsx
index d44a14f..85fd9b9 100644
--- a/mp_web_app/frontend/pages/Gallery.tsx
+++ b/mp_web_app/frontend/pages/Gallery.tsx
@@ -1,6 +1,7 @@
-import {useEffect, useState} from "react";
-import {GalleryImageCard} from "@/components/gallery-image-card";
+import {useEffect, useState, useRef} from "react";
+import {GalleryModal} from "@/components/gallery-modal";
import {API_BASE_URL} from "@/app-config";
+import {LoadingSpinner} from "@/components/ui/loading-spinner";
interface GalleryImage {
id: string;
@@ -9,65 +10,155 @@ interface GalleryImage {
s3_bucket: string;
uploaded_by: string;
created_at: string;
- url?: string; // CloudFront or S3 presigned URL
+ url?: string;
}
export default function Gallery() {
const [images, setImages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [selectedImageIndex, setSelectedImageIndex] = useState(null);
+ const [loadedImages, setLoadedImages] = useState>(new Set());
+ const observerRef = useRef(null);
- useEffect(() => {
- const fetchGallery = async () => {
- try {
- setLoading(true);
- // Fetch gallery images with URLs included
- const response = await fetch(`${API_BASE_URL}gallery/list`);
- if (!response.ok) {
- throw new Error("Failed to fetch gallery");
- }
- const galleryImages = await response.json();
- setImages(galleryImages || []);
- } catch (err: any) {
- setError("Неуспешно зареждане на галерията");
- } finally {
- setLoading(false);
+ const fetchGallery = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`${API_BASE_URL}gallery/list`);
+ if (!response.ok) {
+ throw new Error("Failed to fetch gallery");
}
- };
+ const galleryImages = await response.json();
+ setImages(galleryImages || []);
+ setError(null);
+ } catch (err: any) {
+ setError("Неуспешно зареждане на галерията");
+ } finally {
+ setLoading(false);
+ }
+ };
+ useEffect(() => {
fetchGallery();
}, []);
+ // Lazy loading with Intersection Observer
+ useEffect(() => {
+ observerRef.current = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ const img = entry.target as HTMLImageElement;
+ const src = img.dataset.src;
+ if (src && !loadedImages.has(src)) {
+ img.src = src;
+ setLoadedImages((prev) => new Set(prev).add(src));
+ observerRef.current?.unobserve(img);
+ }
+ }
+ });
+ },
+ {rootMargin: "50px"}
+ );
+
+ return () => observerRef.current?.disconnect();
+ }, [loadedImages]);
+
+ const handleImageClick = (index: number) => {
+ setSelectedImageIndex(index);
+ };
+
return (
-
- {loading && (
-
- Зареждане на галерията...
+
+ {/* Hero Section */}
+
- {error && (
-
- )}
+
+ {loading && (
+
+
+ Зареждане на галерията...
+
+ )}
- {!loading && !error && images.length === 0 && (
-
- )}
+ {error && (
+
+ )}
- {!loading && !error && images.length > 0 && (
-
- {images.map(
- (image) =>
- image.url && (
-
- )
- )}
-
- )}
-
+ {!loading && !error && images.length === 0 && (
+
+ )}
+
+ {!loading && !error && images.length > 0 && (
+ <>
+ {/* Masonry Grid */}
+
+ {images.map((image, index) =>
+ image.url ? (
+ handleImageClick(index)}
+ >
+
+ ![{image.image_name}]() {
+ if (el && observerRef.current) {
+ observerRef.current.observe(el);
+ }
+ }}
+ />
+
+
+
+ ) : null
+ )}
+
+
+ setSelectedImageIndex(null)}
+ imageUrl={selectedImageIndex !== null ? images[selectedImageIndex]?.url || "" : ""}
+ imageName={selectedImageIndex !== null ? images[selectedImageIndex]?.image_name || "" : ""}
+ currentIndex={selectedImageIndex !== null ? selectedImageIndex + 1 : 0}
+ totalImages={images.length}
+ onNext={() => {
+ if (selectedImageIndex !== null && selectedImageIndex < images.length - 1) {
+ setSelectedImageIndex(selectedImageIndex + 1);
+ }
+ }}
+ onPrevious={() => {
+ if (selectedImageIndex !== null && selectedImageIndex > 0) {
+ setSelectedImageIndex(selectedImageIndex - 1);
+ }
+ }}
+ hasNext={selectedImageIndex !== null && selectedImageIndex < images.length - 1}
+ hasPrevious={selectedImageIndex !== null && selectedImageIndex > 0}
+ />
+ >
+ )}
+
+
);
}
diff --git a/mp_web_app/frontend/pages/Home.tsx b/mp_web_app/frontend/pages/Home.tsx
index a95c680..9aec560 100644
--- a/mp_web_app/frontend/pages/Home.tsx
+++ b/mp_web_app/frontend/pages/Home.tsx
@@ -1,9 +1,6 @@
-import {useEffect, useState} from "react";
-import {useLocation} from "react-router-dom";
+import {useState} from "react";
import {NewsCard} from "@/components/news-card";
-import apiClient from "@/context/apiClient";
-import {getAccessToken, setAccessToken} from "@/context/tokenStore";
-import {isJwtExpired} from "@/context/jwt";
+import {useNews} from "@/hooks/useNews";
import {
Pagination,
PaginationContent,
@@ -14,61 +11,15 @@ import {
PaginationPrevious,
} from "@/components/ui/pagination";
-interface News {
- id: string;
- title: string;
- content: string;
- author_id: string;
- created_at: string;
- news_type: "regular" | "private";
-}
-
const PAGE_SIZE = 6;
export default function Home() {
- const location = useLocation();
- const [news, setNews] = useState ([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ // Use React Query hook for caching
+ const {data: news = [], isLoading: loading, error: queryError} = useNews();
const [page, setPage] = useState(1);
-
- useEffect(() => {
- const fetchNews = async () => {
- try {
- setLoading(true);
- setError(null);
-
- // Check if token exists and is expired
- const token = getAccessToken();
- if (token && isJwtExpired(token)) {
- // Token is expired, explicitly refresh it first
- try {
- const refreshResponse = await apiClient.post("auth/refresh");
- // Update token in localStorage with the new one from response
- if (refreshResponse.data?.access_token) {
- setAccessToken(refreshResponse.data.access_token);
- }
- } catch (refreshError) {
- // Refresh failed, user will be logged out by apiClient
- // Just fetch public news
- }
- }
-
- // Now fetch news with fresh token (or no token if refresh failed)
- const response = await apiClient.get("news/list");
- setNews(response.data || []);
- } catch (err: any) {
- if (err.response?.status !== 401) {
- setError("Неуспешно зареждане на новините");
- }
- setNews([]);
- } finally {
- setLoading(false);
- }
- };
-
- fetchNews();
- }, [location.key]); // Refetch when navigation occurs (including browser refresh)
+
+ // Convert error to string for display
+ const error = queryError ? "Неуспешно зареждане на новините" : null;
// Pagination helpers
const total = news.length;
@@ -84,64 +35,90 @@ export default function Home() {
};
return (
-
-
- {loading && (
-
- Зареждане на новини...
-
- )}
-
- {error && (
-
- {error}
-
-
- )}
-
- {!loading && !error && news.length === 0 && (
-
- Няма налични новини
+
+ {/* Hero Section */}
+
+
+
+
+
+ Добре дошли в ГПК
+
+
+ Следете последните новини и актуализации от нашата кооперация
+
+
- )}
-
- {!loading && !error && news.length > 0 && (
- <>
-
- {pageItems.map((item) => (
-
- ))}
+
+
+ {/* News Section */}
+
+ {loading && (
+
+
+ Зареждане на новини...
+
+ )}
+
+ {error && (
+
+
+
+ {error}
+
+
+ )}
+
+ {!loading && !error && news.length === 0 && (
+
+
+
+ Няма налични новини
+
+
+ )}
+
+ {!loading && !error && news.length > 0 && (
+ <>
+
+ {pageItems.map((item, index) => (
+
+
+
+ ))}
+
{/* Pagination */}
{totalPages > 1 && (
-
+
);
}
diff --git a/mp_web_app/frontend/pages/Navigation.tsx b/mp_web_app/frontend/pages/Navigation.tsx
index 2bfb390..79bcf20 100644
--- a/mp_web_app/frontend/pages/Navigation.tsx
+++ b/mp_web_app/frontend/pages/Navigation.tsx
@@ -1,6 +1,5 @@
import {useState, useEffect} from "react";
-import {Link} from "react-router-dom";
-import {Outlet} from "react-router-dom";
+import {Link, useNavigate} from "react-router-dom";
import {useAuth} from "@/context/AuthContext";
import {
NavigationMenu,
@@ -18,8 +17,8 @@ import {Button} from "@/components/ui/button";
const NAV_LINKS = [
{label: "Начало", to: "/home"},
{label: "Продукти", to: "/products"},
- {label: "Контакти", to: "/contacts"},
{label: "Галерия", to: "/gallery"},
+ {label: "Контакти", to: "/contacts"},
{
label: "Списъци",
dropdown: [
@@ -106,18 +105,20 @@ function useWindowWidth() {
export function Navigation() {
const {isLoggedIn, logout} = useAuth();
+ const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [menuAnimating, setMenuAnimating] = useState(false);
+ const [isNavigating, setIsNavigating] = useState(false);
const windowWidth = useWindowWidth();
- const isMobile = windowWidth < 980;
+ const isMobile = windowWidth < 1200;
// Helper to filter dropdown items based on auth
const filterDropdown = (dropdown: any[]) => dropdown.filter((item) => !item.requiresAuth || isLoggedIn);
// Decode role from access token to check role
- const getUserRole = (): "admin" | "board" | "control" | "regular" | null => {
+ const getUserRole = (): "admin" | "board" | "control" | "accountant" | "regular" | null => {
try {
const token = localStorage.getItem("access_token");
if (!token) return null;
@@ -128,7 +129,7 @@ export function Navigation() {
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
const payload = JSON.parse(atob(base64));
const role = String(payload?.role || "").toLowerCase();
- if (role === "admin" || role === "board" || role === "control" || role === "regular") return role as any;
+ if (role === "admin" || role === "board" || role === "control" || role === "accountant" || role === "regular") return role as any;
return null;
} catch {
return null;
@@ -137,15 +138,36 @@ export function Navigation() {
const role = getUserRole();
const isAdmin = role === "admin";
+ const isAccountant = role === "accountant";
const isBoardOrControl = role === "board" || role === "control";
+ // Handle smooth navigation without flickering
+ const handleNavigation = (path: string) => {
+ if (isMobile) {
+ setIsNavigating(true);
+ // Close mobile menu with animation
+ setMobileMenuOpen(false);
+ // Wait for menu close animation to complete before navigating
+ setTimeout(() => {
+ navigate(path);
+ setIsNavigating(false);
+ }, 300);
+ } else {
+ navigate(path);
+ }
+ };
+
// Handle animation for mobile menu
useEffect(() => {
if (mobileMenuOpen) {
setShowMobileMenu(true);
+ // Prevent body scroll when menu is open
+ document.body.style.overflow = 'hidden';
setTimeout(() => setMenuAnimating(true), 10);
} else if (showMobileMenu) {
setMenuAnimating(false);
+ // Restore body scroll
+ document.body.style.overflow = '';
const timeout = setTimeout(() => setShowMobileMenu(false), 300);
return () => clearTimeout(timeout);
}
@@ -156,7 +178,7 @@ export function Navigation() {
- Меню
+ Меню
- |