diff --git a/Makefile b/Makefile index bb57781..f525457 100644 --- a/Makefile +++ b/Makefile @@ -178,18 +178,8 @@ frontend-deploy: frontend-build ## Build and deploy frontend to S3 aws s3 sync mp_web_app/frontend/dist/ s3://frontendstack-frontendsitebucket127f9fa2-zjnv4evaddvi/ --delete @echo "$(GREEN)✓ Frontend deployed to S3$(NC)" -frontend-invalidate: ## Invalidate CloudFront cache - @echo "$(BLUE)Invalidating CloudFront cache...$(NC)" - @DISTRIBUTION_ID=$$(aws cloudfront list-distributions --query "DistributionList.Items[?Origins.Items[?DomainName=='frontendstack-frontendsitebucket127f9fa2-zjnv4evaddit.s3.amazonaws.com']].Id" --output text); \ - if [ -z "$$DISTRIBUTION_ID" ]; then \ - echo "$(RED)✗ CloudFront distribution not found$(NC)"; \ - exit 1; \ - fi; \ - aws cloudfront create-invalidation --distribution-id $$DISTRIBUTION_ID --paths "/*"; \ - echo "$(GREEN)✓ CloudFront cache invalidated (Distribution: $$DISTRIBUTION_ID)$(NC)" - -frontend-deploy-all: frontend-deploy frontend-invalidate ## Build, deploy to S3, and invalidate CloudFront - @echo "$(GREEN)✓ Frontend fully deployed and cache invalidated$(NC)" +frontend-deploy-all: frontend-deploy ## Build, deploy to S3 + @echo "$(GREEN)✓ Frontend fully deployed$(NC)" ##@ Utilities diff --git a/mp_web_app/backend/api.py b/mp_web_app/backend/api.py index 0259e8c..92bf3aa 100644 --- a/mp_web_app/backend/api.py +++ b/mp_web_app/backend/api.py @@ -9,6 +9,7 @@ from gallery.routers import gallery_router from mail.routers import mail_router from members.routers import member_router +from middleware.cache_headers import CacheControlMiddleware from news.routers import news_router from products.routers import product_router from users.routers import user_router @@ -25,6 +26,9 @@ allow_headers=["*"], ) +# Add cache control middleware +app.add_middleware(CacheControlMiddleware) + app.include_router(user_router, prefix="/api/users") app.include_router(auth_router, prefix="/api/auth") app.include_router(mail_router, prefix="/api/mail") diff --git a/mp_web_app/backend/auth/operations.py b/mp_web_app/backend/auth/operations.py index 247eabb..9222de5 100644 --- a/mp_web_app/backend/auth/operations.py +++ b/mp_web_app/backend/auth/operations.py @@ -5,7 +5,7 @@ from typing import Literal, Optional from uuid import uuid4 -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from pydantic import EmailStr, ValidationError @@ -20,7 +20,7 @@ from users.roles import ROLE_HIERARCHY, UserRole REFRESH_TABLE_NAME = os.environ.get("REFRESH_TABLE_NAME") -ACCESS_TOKEN_EXPIRE_MINUTES = 1 +ACCESS_TOKEN_EXPIRE_MINUTES = 5 REFRESH_TOKEN_EXPIRE_DAYS = 7 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") @@ -157,6 +157,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), repo: UserRepository = user = get_user_by_id(user_id, repo) if not user: raise UserNotFoundError("User not found") + # Check if user account is active + if not user.active: + raise UnauthorizedError("Account is not active") return user except UnauthorizedError as e: raise HTTPException(status_code=401, detail=str(e)) @@ -170,6 +173,10 @@ def authenticate_user(email: str, password: str, repo: UserRepository) -> Option if not user: return None + # Check if user account is active + if not user.active: + return None + if verify_password(password_hash=user.password_hash, password=password, salt=user.salt): return user return None diff --git a/mp_web_app/backend/files/models.py b/mp_web_app/backend/files/models.py index 00ba473..57a05d4 100644 --- a/mp_web_app/backend/files/models.py +++ b/mp_web_app/backend/files/models.py @@ -19,6 +19,7 @@ class FileMetadata(BaseModel): file_name: str | None = None file_type: FileType uploaded_by: str | None = None + uploaded_by_name: str | None = None # User's full name for display created_at: str = datetime.now().isoformat() diff --git a/mp_web_app/backend/files/operations.py b/mp_web_app/backend/files/operations.py index bbad6bd..f45576d 100644 --- a/mp_web_app/backend/files/operations.py +++ b/mp_web_app/backend/files/operations.py @@ -10,7 +10,7 @@ from fastapi.responses import StreamingResponse from app_config import AllowedFileExtensions -from database.repositories import FileMetadataRepository +from database.repositories import FileMetadataRepository, UserRepository from files.exceptions import ( FileAccessDeniedError, FileNotFoundError, @@ -27,6 +27,7 @@ BUCKET = os.environ.get("UPLOADS_BUCKET") UPLOADS_TABLE_NAME = os.environ.get("UPLOADS_TABLE_NAME") +USERS_TABLE_NAME = os.environ.get("USERS_TABLE_NAME") @lru_cache @@ -35,6 +36,36 @@ def get_allowed_file_extensions(): return AllowedFileExtensions().allowed_file_extensions +def _enrich_with_user_names(files_metadata: list[FileMetadata]): + """Enrich file metadata with uploader names.""" + if not files_metadata: + return + + # Collect unique user IDs + user_ids = {fm.uploaded_by for fm in files_metadata if fm.uploaded_by} + if not user_ids: + return + + # Fetch users and create a map + user_repo = UserRepository(USERS_TABLE_NAME) + users_map = {} + + for user_id in user_ids: + try: + response = user_repo.table.get_item(Key={"id": user_id}) + if "Item" in response: + user = user_repo.convert_item_to_object(response["Item"]) + users_map[user.id] = f"{user.first_name} {user.last_name}" + except Exception: + # If fetch fails, continue without this user's name + continue + + # Enrich file metadata with user names + for fm in files_metadata: + if fm.uploaded_by: + fm.uploaded_by_name = users_map.get(fm.uploaded_by, fm.uploaded_by) + + def get_uploads_repository(): return FileMetadataRepository(UPLOADS_TABLE_NAME) @@ -92,6 +123,10 @@ def get_files_metadata(file_type: str, repo: FileMetadataRepository): items.extend(response["Items"]) files_metadata = [repo.convert_item_to_object(item) for item in items] + + # Enrich with user names + _enrich_with_user_names(files_metadata) + return files_metadata except Exception as e: @@ -126,21 +161,23 @@ def _create_file_name(file_name: str, original_name: str): extension = original_name.split(".")[-1] if extension not in allowed: raise InvalidFileExtensionError(extension, allowed) - cleaned_file_name = re.sub(r"[^A-Za-z0-9.\-_\s]", "", file_name).strip() - file_name_parts = re.split(r"[.\s\-_]", cleaned_file_name) + # Keep Cyrillic and other Unicode characters, only remove special chars that break S3 + cleaned_file_name = re.sub(r'[<>:"/\\|?*]', "", file_name).strip() + # Replace spaces with underscores for cleaner URLs + cleaned_file_name = cleaned_file_name.replace(" ", "_") date_tag = f"{now.year!s}_{str(now.month).zfill(2)}_{str(now.day).zfill(2)}" - file_name = f"{date_tag}_{'_'.join([p.lower() for p in file_name_parts if p != ''])}_{str(uuid4())[:8]}.{extension}" + file_name = f"{date_tag}_{cleaned_file_name}_{str(uuid4())[:8]}.{extension}" return file_name def download_file(file_metadata: FileMetadata | list[FileMetadata], user: User, repo: FileMetadataRepository): file_meta_object = get_db_metadata(file_metadata, repo) - user_id = None - if user.role != UserRole.REGULAR_USER.value: - user_id = user.id + # All logged-in users should have access to their allowed files + user_id = user.id + user_role = user.role - is_allowed = _check_file_allowed_to_user(file_meta_object, user_id) + is_allowed = _check_file_allowed_to_user(file_meta_object, user_id, user_role) if not is_allowed: raise FileAccessDeniedError(file_meta_object.file_name) @@ -148,10 +185,16 @@ def download_file(file_metadata: FileMetadata | list[FileMetadata], user: User, try: s3_object = s3.get_object(Bucket=file_meta_object.bucket, Key=file_meta_object.key) file_stream = s3_object["Body"] + + # Properly encode filename for Cyrillic and other Unicode characters + from urllib.parse import quote + + encoded_filename = quote(file_meta_object.file_name) + return StreamingResponse( file_stream, media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="{file_meta_object.key}"'}, + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}, ) except s3.exceptions.NoSuchKey: raise FileNotFoundError("File not found") @@ -175,26 +218,47 @@ def _validate_metadata(file_metadata: FileMetadata, db_meta_object: FileMetadata return file_metadata.model_dump() == db_meta_object.model_dump(include=fields) -def _check_file_allowed_to_user(file_metadata: FileMetadata, user_id: str | None = None) -> bool: - public_allowed_types = [ +def _check_file_allowed_to_user(file_metadata: FileMetadata, user_id: str, user_role: str) -> bool: + # Public documents - accessible to everyone (logged in or not) + public_types = [ FileType.governing_documents.value, FileType.forms.value, ] - private_allowed_types = [ + # Documents accessible to all logged-in users + logged_in_types = [ FileType.minutes.value, - FileType.governing_documents.value, - FileType.forms.value, FileType.transcripts.value, - FileType.accounting.value, - FileType.private_documents.value, FileType.others.value, ] - is_allowed_to_user = True - if user_id: - is_allowed_type = file_metadata.file_type.value in private_allowed_types + + # Accounting documents - only for admin, board, control, accountant + accounting_allowed_roles = [ + UserRole.ADMIN.value, + UserRole.BOARD.value, + UserRole.CONTROL.value, + UserRole.ACCOUNTANT.value, + ] + + file_type = file_metadata.file_type.value + + # Public documents - always allowed + if file_type in public_types: + return True + + # Documents for all logged-in users + if file_type in logged_in_types: + return True + + # Accounting documents - role-based access + if file_type == FileType.accounting.value: + return user_role in accounting_allowed_roles + + # Private documents - only for specified users + if file_type == FileType.private_documents.value: if file_metadata.allowed_to: - is_allowed_to_user = user_id in file_metadata.allowed_to - else: - is_allowed_type = file_metadata.file_type.value in public_allowed_types - return is_allowed_type and is_allowed_to_user + return user_id in file_metadata.allowed_to + return False + + # Default deny + return False diff --git a/mp_web_app/backend/files/routers.py b/mp_web_app/backend/files/routers.py index 57054f6..3ed1837 100644 --- a/mp_web_app/backend/files/routers.py +++ b/mp_web_app/backend/files/routers.py @@ -25,9 +25,13 @@ async def file_create( allowed_to: list[str] = Form([]), file: UploadFile = File(...), repo: FileMetadataRepository = Depends(get_uploads_repository), - user=Depends(role_required([UserRole.ADMIN])), + user=Depends(role_required([UserRole.ADMIN, UserRole.ACCOUNTANT])), ): try: + # Accountants can only upload accounting documents + if user.role == UserRole.ACCOUNTANT.value and file_type != FileType.accounting: + raise HTTPException(status_code=403, detail="Accountants can only upload accounting documents") + file_metadata = FileMetadataFull( file_name=file_name, file_type=file_type, allowed_to=allowed_to, uploaded_by=user.id ) @@ -46,9 +50,19 @@ async def file_create( async def files_list( file_type: str, repo: FileMetadataRepository = Depends(get_uploads_repository), - user=Depends(role_required([UserRole.ADMIN])), + user=Depends( + role_required([UserRole.REGULAR_USER, UserRole.ACCOUNTANT, UserRole.BOARD, UserRole.CONTROL, UserRole.ADMIN]) + ), ): try: + # Accountants can only list accounting documents + if user.role == UserRole.ACCOUNTANT.value and file_type != "accounting": + raise HTTPException(status_code=403, detail="Accountants can only access accounting documents") + + # Regular users cannot list accounting documents + if user.role == UserRole.REGULAR_USER.value and file_type == "accounting": + raise HTTPException(status_code=403, detail="You don't have access to this document type") + return get_files_metadata(file_type, repo) except MetadataError as e: raise HTTPException(status_code=500, detail=str(e)) @@ -58,10 +72,20 @@ async def files_list( async def file_delete( file_id: str, repo: FileMetadataRepository = Depends(get_uploads_repository), - user=Depends(role_required([UserRole.ADMIN])), + user=Depends(role_required([UserRole.ADMIN, UserRole.ACCOUNTANT])), ): - """Delete a single file by ID (ADMIN only).""" + """Delete a single file by ID (ADMIN and ACCOUNTANT).""" try: + # Accountants can only delete accounting documents + if user.role == UserRole.ACCOUNTANT.value: + response = repo.table.get_item(Key={"id": file_id}) + if "Item" not in response: + raise HTTPException(status_code=404, detail="File not found") + + file_metadata = repo.convert_item_to_object_full(response["Item"]) + if file_metadata.file_type != FileType.accounting: + raise HTTPException(status_code=403, detail="Accountants can only delete accounting documents") + delete_file(file_id, repo) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/mp_web_app/backend/gallery/operations.py b/mp_web_app/backend/gallery/operations.py index 401c4f9..35e34d7 100644 --- a/mp_web_app/backend/gallery/operations.py +++ b/mp_web_app/backend/gallery/operations.py @@ -23,6 +23,8 @@ USE_CLOUDFRONT = os.environ.get("USE_CLOUDFRONT", "false").lower() == "true" ALLOWED_IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] +MAX_IMAGE_SIZE_MB = 15 # Maximum image size in MB +MAX_IMAGE_SIZE_BYTES = MAX_IMAGE_SIZE_MB * 1024 * 1024 def get_gallery_repository() -> GalleryRepository: @@ -35,10 +37,23 @@ def upload_gallery_image( ) -> GalleryImageMetadata: """Upload gallery image to S3 and store metadata in DynamoDB.""" # Validate file extension + if not file.filename or "." not in file.filename: + raise InvalidImageFormatError(ALLOWED_IMAGE_EXTENSIONS) + file_extension = file.filename.split(".")[-1].lower() if file_extension not in ALLOWED_IMAGE_EXTENSIONS: raise InvalidImageFormatError(ALLOWED_IMAGE_EXTENSIONS) + # Validate file size + file.file.seek(0, 2) # Seek to end of file + file_size = file.file.tell() # Get current position (file size) + file.file.seek(0) # Reset to beginning + + if file_size > MAX_IMAGE_SIZE_BYTES: + raise ImageUploadError( + f"File too large. Maximum size: {MAX_IMAGE_SIZE_MB}MB. Your file: {file_size / 1024 / 1024:.2f}MB" + ) + # Generate unique S3 key image_id = str(uuid4()) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -79,11 +94,11 @@ def upload_gallery_image( def get_gallery_images(repo: GalleryRepository, include_urls: bool = True): """ Retrieve all gallery images from DynamoDB. - + Args: repo: Gallery repository include_urls: If True, adds 'url' field with CloudFront/S3 URL to each image - + Returns: List of gallery image objects with optional URLs """ @@ -103,7 +118,7 @@ def get_gallery_images(repo: GalleryRepository, include_urls: bool = True): items.extend(response["Items"]) images = [repo.convert_item_to_object(item) for item in items] - + # Add URLs to each image if requested if include_urls: for image in images: @@ -112,7 +127,7 @@ def get_gallery_images(repo: GalleryRepository, include_urls: bool = True): except Exception: # If URL generation fails, skip this image image.url = None - + return images except ClientError as e: raise DatabaseError(f"Failed to fetch gallery images: {e.response['Error']['Message']}") @@ -151,14 +166,14 @@ def delete_gallery_image(image_id: str, repo: GalleryRepository) -> bool: def generate_presigned_url(s3_key: str, bucket: str, expiration: int = 3600) -> str: """ Generate URL for image access. - + If CloudFront is enabled, returns CloudFront URL (permanent, cached). Otherwise, returns S3 presigned URL (temporary, direct to S3). """ # Use CloudFront if configured (recommended for production) if USE_CLOUDFRONT and CLOUDFRONT_DOMAIN: return f"https://{CLOUDFRONT_DOMAIN}/{s3_key}" - + # Fallback to S3 presigned URL s3 = boto3.client("s3") try: diff --git a/mp_web_app/backend/gallery/routers.py b/mp_web_app/backend/gallery/routers.py index 665ceae..2c7dea1 100644 --- a/mp_web_app/backend/gallery/routers.py +++ b/mp_web_app/backend/gallery/routers.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from auth.operations import role_required -from database.repositories import GalleryRepository from database.exceptions import DatabaseError +from database.repositories import GalleryRepository from gallery.exceptions import ( ImageNotFoundError, ImageUploadError, @@ -35,11 +35,11 @@ async def gallery_create( except InvalidImageFormatError as e: raise HTTPException(status_code=400, detail=str(e)) except ImageUploadError as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) except DatabaseError as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=f"Database error: {e!s}") except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=f"Image upload failed: {e!s}") @gallery_router.get("/list", status_code=status.HTTP_200_OK) diff --git a/mp_web_app/backend/members/operations.py b/mp_web_app/backend/members/operations.py index 1f7a2de..b92ec09 100644 --- a/mp_web_app/backend/members/operations.py +++ b/mp_web_app/backend/members/operations.py @@ -120,6 +120,7 @@ def _normalize_members(new_members_list: list[dict[str, Any]]): "email": member["email"] if member["email"] else None, "member_code": member["member_code"], "member_code_valid": True, + "proxy": member["proxy"] in ["1", "yes", "true"], } ) return normalized_members_list diff --git a/mp_web_app/backend/members/routers.py b/mp_web_app/backend/members/routers.py index 659481b..c6e7d6b 100644 --- a/mp_web_app/backend/members/routers.py +++ b/mp_web_app/backend/members/routers.py @@ -34,8 +34,10 @@ async def members_list( Access: - All authenticated users can access this endpoint - - ADMIN, BOARD, CONTROL: See full details (name, email, phone) - - REGULAR_USER: See only names (first_name, last_name, proxy) + - ADMIN, BOARD, CONTROL: See full details (name, email, phone, member_code) + - REGULAR_USER, ACCOUNTANT: + - For cooperative members: See only names (first_name, last_name) + - For proxies: See names + email Query Parameters: - proxy_only: If True, returns only members with proxy=True. Default is False (returns all members). @@ -47,8 +49,24 @@ async def members_list( user_role = current_user.role if isinstance(current_user.role, UserRole) else UserRole(current_user.role) if user_role in privileged_roles: + # Admin, Board, Control see everything return members + elif proxy_only: + # For proxies list: all logged users see email (but not phone/member_code) + return [ + Member( + first_name=m.first_name, + last_name=m.last_name, + email=m.email, + phone=None, # Hide phone for regular users + member_code="", # Hide member code + member_code_valid=False, + proxy=m.proxy, + ) + for m in members + ] else: + # For cooperative members: regular users see only names return [MemberPublic(first_name=m.first_name, last_name=m.last_name, proxy=m.proxy) for m in members] except DatabaseError as e: raise HTTPException(status_code=500, detail=str(e)) @@ -101,7 +119,7 @@ async def member_delete( raise HTTPException(status_code=500, detail=str(e)) -@member_router.post("/sync_members_list", status_code=status.HTTP_200_OK) +@member_router.post("/sync_members", status_code=status.HTTP_200_OK) async def members_upload( file: UploadFile, member_repo: MemberRepository = Depends(get_member_repository), diff --git a/mp_web_app/backend/middleware/cache_headers.py b/mp_web_app/backend/middleware/cache_headers.py new file mode 100644 index 0000000..43c4b2a --- /dev/null +++ b/mp_web_app/backend/middleware/cache_headers.py @@ -0,0 +1,56 @@ +# middleware/cache_headers.py +""" +Middleware to add cache control headers to responses. +""" + +from collections.abc import Callable +from typing import ClassVar + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + + +class CacheControlMiddleware(BaseHTTPMiddleware): + """ + Middleware that adds Cache-Control headers based on the endpoint. + """ + + # Define cache policies for different endpoint patterns + CACHE_POLICIES: ClassVar[dict[str, str]] = { + # Long cache (1 hour) - rarely changing data + "products/list": "public, max-age=3600, stale-while-revalidate=7200", + "gallery/list": "public, max-age=3600, stale-while-revalidate=7200", + "users/board": "public, max-age=3600, stale-while-revalidate=7200", + "users/control": "public, max-age=3600, stale-while-revalidate=7200", + "members/list": "public, max-age=3600, stale-while-revalidate=7200", + "files/list": "public, max-age=3600, stale-while-revalidate=7200", + # Medium cache (10 minutes) - occasionally changing data + "news/list": "public, max-age=600, stale-while-revalidate=1200", + # Short cache (5 minutes) - admin panel data + "users/list": "private, max-age=300, stale-while-revalidate=600", + } + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + response = await call_next(request) + + # Only cache GET requests with successful responses + if request.method == "GET" and 200 <= response.status_code < 300: + path = request.url.path.removeprefix("/api/") + + # Check if this path has a cache policy + for pattern, cache_control in self.CACHE_POLICIES.items(): + if pattern in path: + response.headers["Cache-Control"] = cache_control + # Vary by Authorization to cache authenticated vs public separately + response.headers["Vary"] = "Authorization" + break + else: + # Default: no cache for unmatched GET endpoints + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + else: + # Never cache mutations or errors + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return response diff --git a/mp_web_app/backend/products/operations.py b/mp_web_app/backend/products/operations.py index 343c6c2..0ca9cbf 100644 --- a/mp_web_app/backend/products/operations.py +++ b/mp_web_app/backend/products/operations.py @@ -1,5 +1,4 @@ import os -from decimal import Decimal from typing import Any from uuid import uuid4 @@ -51,7 +50,7 @@ def update_product(product_update: ProductUpdate, product: Product, repo: Produc if product_update.name is not None: update_expression_parts.append("#name = :name") expression_attribute_values[":name"] = product_update.name - expression_attribute_names[":name"] = "name" + expression_attribute_names["#name"] = "name" if product_update.length is not None: update_expression_parts.append("#length = :length") diff --git a/mp_web_app/backend/users/models.py b/mp_web_app/backend/users/models.py index cf66ace..330878b 100644 --- a/mp_web_app/backend/users/models.py +++ b/mp_web_app/backend/users/models.py @@ -46,5 +46,6 @@ class UserSecret(BaseModel): email: EmailStr member_code: str role: str + active: bool salt: str password_hash: str diff --git a/mp_web_app/backend/users/operations.py b/mp_web_app/backend/users/operations.py index 94cdb47..92d9883 100644 --- a/mp_web_app/backend/users/operations.py +++ b/mp_web_app/backend/users/operations.py @@ -152,9 +152,7 @@ def list_users(repo: UserRepository) -> list[User]: return [repo.convert_item_to_object(item) for item in response["Items"]] -def update_user( - user_id: str, user_email: EmailStr | str, user_data: UserUpdate, repo: UserRepository -) -> User: +def update_user(user_id: str, user_email: EmailStr | str, user_data: UserUpdate, repo: UserRepository) -> User: """Update a user in DynamoDB. Raises UserNotFoundError if not found.""" existing_user = get_user_by_email(user_email, repo) diff --git a/mp_web_app/backend/users/roles.py b/mp_web_app/backend/users/roles.py index 1446b92..c2d4303 100644 --- a/mp_web_app/backend/users/roles.py +++ b/mp_web_app/backend/users/roles.py @@ -5,12 +5,14 @@ class UserRole(StrEnum): REGULAR_USER = "regular" BOARD = "board" CONTROL = "control" + ACCOUNTANT = "accountant" ADMIN = "admin" ROLE_HIERARCHY: dict[UserRole, list[UserRole]] = { - UserRole.ADMIN: [UserRole.ADMIN, UserRole.CONTROL, UserRole.BOARD, UserRole.REGULAR_USER], + UserRole.ADMIN: [UserRole.ADMIN, UserRole.CONTROL, UserRole.BOARD, UserRole.ACCOUNTANT, UserRole.REGULAR_USER], UserRole.CONTROL: [UserRole.CONTROL, UserRole.BOARD, UserRole.REGULAR_USER], UserRole.BOARD: [UserRole.BOARD, UserRole.REGULAR_USER], + UserRole.ACCOUNTANT: [UserRole.ACCOUNTANT], UserRole.REGULAR_USER: [UserRole.REGULAR_USER], } diff --git a/mp_web_app/backend/users/routers.py b/mp_web_app/backend/users/routers.py index ca64a5f..61fc0e5 100644 --- a/mp_web_app/backend/users/routers.py +++ b/mp_web_app/backend/users/routers.py @@ -3,7 +3,7 @@ from starlette.responses import RedirectResponse from app_config import FRONTEND_BASE_URL -from auth.operations import decode_token, is_token_expired, role_required +from auth.operations import decode_token, get_current_user, is_token_expired, role_required from database.repositories import MemberRepository, UserRepository from mail.operations import construct_verification_link, send_verification_email from members.operations import get_member_repository, is_member_code_valid, update_member_code @@ -24,9 +24,16 @@ user_router = APIRouter(tags=["users"]) +@user_router.get("/me", response_model=User, status_code=status.HTTP_200_OK) +async def get_me(current_user: User = Depends(get_current_user)): + """Get current authenticated user information.""" + return current_user + + @user_router.get("/list", response_model=list[User], status_code=status.HTTP_200_OK) async def users_list( - user_repo: UserRepository = Depends(get_user_repository), user=Depends(role_required([UserRole.REGULAR_USER])) + user_repo: UserRepository = Depends(get_user_repository), + user=Depends(role_required([UserRole.REGULAR_USER, UserRole.ACCOUNTANT])), ): try: return list_users(user_repo) @@ -146,6 +153,12 @@ async def user_update( ): """Update a user (ADMIN only).""" try: + # Validate role if provided + if user_data.role is not None: + valid_roles = [role.value for role in UserRole] + if user_data.role not in valid_roles: + raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(valid_roles)}") + # Get user by ID to get their email existing_user = get_user_by_id(user_id, user_repo) return update_user(user_id, existing_user.email, user_data, user_repo) diff --git a/mp_web_app/frontend/app-config.ts b/mp_web_app/frontend/app-config.ts index 57e38b7..58fd49e 100644 --- a/mp_web_app/frontend/app-config.ts +++ b/mp_web_app/frontend/app-config.ts @@ -3,5 +3,5 @@ // For production, use your custom API domain. // Make sure the subdomain matches the one in your cdk/app.py -export const API_BASE_URL = "https://api.ivan-partsunev.com/api/"; -// export const API_BASE_URL = "http://127.0.0.1:8000/api/"; +// export const API_BASE_URL = "https://api.ivan-partsunev.com/api/"; +export const API_BASE_URL = "http://127.0.0.1:8000/api/"; diff --git a/mp_web_app/frontend/components/admin-layout.tsx b/mp_web_app/frontend/components/admin-layout.tsx index 07b51a1..269cc38 100644 --- a/mp_web_app/frontend/components/admin-layout.tsx +++ b/mp_web_app/frontend/components/admin-layout.tsx @@ -8,12 +8,12 @@ interface AdminLayoutProps { export function AdminLayout({title, children}: AdminLayoutProps) { return ( -
+
{title} - {children} + {children}
); diff --git a/mp_web_app/frontend/components/files-table.tsx b/mp_web_app/frontend/components/files-table.tsx index a36f7f0..6a888ce 100644 --- a/mp_web_app/frontend/components/files-table.tsx +++ b/mp_web_app/frontend/components/files-table.tsx @@ -13,23 +13,7 @@ import { PaginationPrevious, } from "@/components/ui/pagination"; import apiClient from "@/context/apiClient"; - -type FileType = - | "governing_documents" - | "forms" - | "minutes" - | "transcripts" - | "accounting" - | "private_documents" - | "others"; - -type FileMetadata = { - id?: string | null; - file_name?: string | null; - file_type: FileType; - uploaded_by?: string | null; - created_at?: string | null; -}; +import {useFiles, type FileType, type FileMetadata} from "@/hooks/useFiles"; type FilesTableProps = { fileType: FileType; @@ -39,31 +23,12 @@ type FilesTableProps = { const PAGE_SIZE = 25; export function FilesTable({fileType, title = "Документи"}: FilesTableProps) { - const [data, setData] = React.useState([]); - const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(null); + // Use React Query hook for caching (1 hour) + const {data = [], isLoading: loading, error: queryError} = useFiles(fileType); const [page, setPage] = React.useState(1); - - const load = React.useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await apiClient.get(`files/list`, { - params: {file_type: fileType}, - withCredentials: true, - }); - setData(res.data ?? []); - setPage(1); // reset page on type change/fresh load - } catch (e: any) { - setError(e?.response?.data?.detail ?? "Възникна грешка при зареждане."); - } finally { - setLoading(false); - } - }, [fileType]); - - React.useEffect(() => { - load(); - }, [load]); + + // Convert error to string for display + const error = queryError ? "Възникна грешка при зареждане." : null; // Pagination helpers const total = data.length; @@ -119,56 +84,74 @@ export function FilesTable({fileType, title = "Документи"}: FilesTableP }; return ( -
- {title &&

{title}

} +
+ {/* Hero Section */} + {title && ( +
+
+
+
+

+ {title} +

+
+
+
+ )} + +
Списък с налични файлове - +
- +
- - Име на файл - Дата на създаване - Действия + + Име на файл + Дата на създаване + Действия {loading && ( - + Зареждане... )} {error && !loading && ( - + {error} )} {!loading && !error && pageItems.length === 0 && ( - + Няма налични записи. )} {!loading && !error && - pageItems.map((file, idx) => ( + pageItems.map((file: FileMetadata, idx: number) => ( - {startIdx + idx + 1} - {file.file_name} - - {file.created_at ? new Date(file.created_at).toLocaleString() : "-"} + {startIdx + idx + 1} + {file.file_name} + + {file.created_at ? new Date(file.created_at).toLocaleDateString('bg-BG', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }) : "-"} - + @@ -289,6 +272,7 @@ export function FilesTable({fileType, title = "Документи"}: FilesTableP )} - + + ); } diff --git a/mp_web_app/frontend/components/footer.tsx b/mp_web_app/frontend/components/footer.tsx new file mode 100644 index 0000000..3e60e46 --- /dev/null +++ b/mp_web_app/frontend/components/footer.tsx @@ -0,0 +1,24 @@ +export function Footer() { + const currentYear = new Date().getFullYear(); + + return ( +
+ {/* Subtle gradient overlay */} +
+ + {/* Top accent line */} +
+ +
+
+

+ © {currentYear} Горовладелческа Производителна Кооперация "Мурджов Пожар" +

+

+ Всички права запазени +

+
+
+
+ ); +} diff --git a/mp_web_app/frontend/components/gallery-image-card.tsx b/mp_web_app/frontend/components/gallery-image-card.tsx index 8e66ff3..12dd538 100644 --- a/mp_web_app/frontend/components/gallery-image-card.tsx +++ b/mp_web_app/frontend/components/gallery-image-card.tsx @@ -1,14 +1,13 @@ import React, {useState} from "react"; import {Card} from "@/components/ui/card"; -import {Dialog, DialogContent, DialogTitle} from "@/components/ui/dialog"; interface GalleryImageCardProps { imageUrl: string; imageName: string; + onClick?: () => void; } -export function GalleryImageCard({imageUrl, imageName}: GalleryImageCardProps) { - const [isOpen, setIsOpen] = useState(false); +export function GalleryImageCard({imageUrl, imageName, onClick}: GalleryImageCardProps) { const [imageLoaded, setImageLoaded] = useState(false); const [aspectRatio, setAspectRatio] = useState<"portrait" | "landscape" | "square">("square"); @@ -29,41 +28,26 @@ export function GalleryImageCard({imageUrl, imageName}: GalleryImageCardProps) { }; return ( - <> - setIsOpen(true)} - > -
- {!imageLoaded && ( -
-

Зареждане...

-
- )} - {imageName} -
-
- - - - {imageName} -
- {imageName} + +
+ {!imageLoaded && ( +
+

Зареждане...

- -
- + )} + {imageName} + + ); } diff --git a/mp_web_app/frontend/components/gallery-modal.tsx b/mp_web_app/frontend/components/gallery-modal.tsx new file mode 100644 index 0000000..a285c6a --- /dev/null +++ b/mp_web_app/frontend/components/gallery-modal.tsx @@ -0,0 +1,153 @@ +import {useEffect, useRef, useState} from "react"; +import {Dialog, DialogContent, DialogTitle} from "@/components/ui/dialog"; +import {Button} from "@/components/ui/button"; +import {ChevronLeft, ChevronRight, X} from "lucide-react"; + +interface GalleryModalProps { + isOpen: boolean; + onClose: () => void; + imageUrl: string; + imageName: string; + currentIndex: number; + totalImages: number; + onNext: () => void; + onPrevious: () => void; + hasNext: boolean; + hasPrevious: boolean; +} + +export function GalleryModal({ + isOpen, + onClose, + imageUrl, + imageName, + currentIndex, + totalImages, + onNext, + onPrevious, + hasNext, + hasPrevious, +}: GalleryModalProps) { + const [touchStart, setTouchStart] = useState(null); + const [touchEnd, setTouchEnd] = useState(null); + const imageRef = useRef(null); + + // Minimum swipe distance (in px) + const minSwipeDistance = 50; + + // Keyboard navigation + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowRight" && hasNext) { + onNext(); + } else if (e.key === "ArrowLeft" && hasPrevious) { + onPrevious(); + } else if (e.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, hasNext, hasPrevious, onNext, onPrevious, onClose]); + + // Touch gesture handlers + const onTouchStart = (e: React.TouchEvent) => { + setTouchEnd(null); + setTouchStart(e.targetTouches[0].clientX); + }; + + const onTouchMove = (e: React.TouchEvent) => { + setTouchEnd(e.targetTouches[0].clientX); + }; + + const onTouchEnd = () => { + if (!touchStart || !touchEnd) return; + + const distance = touchStart - touchEnd; + const isLeftSwipe = distance > minSwipeDistance; + const isRightSwipe = distance < -minSwipeDistance; + + if (isLeftSwipe && hasNext) { + onNext(); + } else if (isRightSwipe && hasPrevious) { + onPrevious(); + } + + setTouchStart(null); + setTouchEnd(null); + }; + + return ( + + + {imageName} + + {/* Close button */} + + + {/* Image counter - hidden on mobile */} +
+ {currentIndex} / {totalImages} +
+ + {/* Previous button - hidden on mobile, use swipe instead */} + {hasPrevious && ( + + )} + + {/* Next button - hidden on mobile, use swipe instead */} + {hasNext && ( + + )} + + {/* Image container with touch gestures */} +
+
+ {imageName} +
+
+
+
+ ); +} diff --git a/mp_web_app/frontend/components/header.tsx b/mp_web_app/frontend/components/header.tsx new file mode 100644 index 0000000..2ce5ba4 --- /dev/null +++ b/mp_web_app/frontend/components/header.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { Logo } from "@/components/logo"; + +export function Header() { + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setScrolled(window.scrollY > 20); + }; + + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + return ( +
+
+ {/* Subtle gradient overlay */} +
+ +
+
+ ); +} diff --git a/mp_web_app/frontend/components/logo.tsx b/mp_web_app/frontend/components/logo.tsx index 7d6250e..fb7f6fd 100644 --- a/mp_web_app/frontend/components/logo.tsx +++ b/mp_web_app/frontend/components/logo.tsx @@ -1,26 +1,42 @@ export function Logo() { + return ( -
- {/* background image */} +
+ {/* Forest-themed hero background image */}
- - Име - Дължина (см) - Ширина (см) - Височина (см) - Описание + + Име + Дължина (см) + Ширина (см) + Височина (см) + Описание @@ -109,19 +83,18 @@ export function ProductsTable({title = "Продукти"}: ProductsTableProps) )} {!loading && !error && - pageItems.map((product, idx) => ( + pageItems.map((product: Product, idx: number) => ( {startIdx + idx + 1} {product.name} {product.length ?? "-"} {product.width ?? "-"} {product.height ?? "-"} - {product.description || "-"} + {product.description || "-"} ))}
-
{/* Pagination footer */} {totalPages > 1 && ( diff --git a/mp_web_app/frontend/components/ui/alert-dialog.tsx b/mp_web_app/frontend/components/ui/alert-dialog.tsx index ec41ed7..3a9696d 100644 --- a/mp_web_app/frontend/components/ui/alert-dialog.tsx +++ b/mp_web_app/frontend/components/ui/alert-dialog.tsx @@ -18,34 +18,40 @@ function AlertDialogPortal({...props}: React.ComponentProps; } -function AlertDialogOverlay({className, ...props}: React.ComponentProps) { - return ( - , + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + + + - ); -} - -function AlertDialogContent({className, ...props}: React.ComponentProps) { - return ( - - - - - ); -} + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; function AlertDialogHeader({className, ...props}: React.ComponentProps<"div">) { return ( diff --git a/mp_web_app/frontend/components/ui/button.tsx b/mp_web_app/frontend/components/ui/button.tsx index 1dbc226..b9386e7 100644 --- a/mp_web_app/frontend/components/ui/button.tsx +++ b/mp_web_app/frontend/components/ui/button.tsx @@ -1,30 +1,31 @@ import * as React from "react"; import {Slot} from "@radix-ui/react-slot"; import {cva, type VariantProps} from "class-variance-authority"; +import {Loader2} from "lucide-react"; import {cn} from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 overflow-hidden group", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: "bg-gradient-to-r from-primary to-primary/90 text-white shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/40 hover:scale-105 active:scale-95 before:absolute before:inset-0 before:bg-gradient-to-r before:from-white/20 before:to-transparent before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-gradient-to-r from-red-600 to-red-700 text-white shadow-lg shadow-red-500/25 hover:shadow-xl hover:shadow-red-500/40 hover:scale-105 active:scale-95", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", + "border-2 border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-900/50 backdrop-blur-sm hover:border-primary hover:bg-primary/5 hover:scale-105 active:scale-95 shadow-sm hover:shadow-md", + secondary: "bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-700 text-gray-900 dark:text-white shadow-md hover:shadow-lg hover:scale-105 active:scale-95", + ghost: "hover:bg-gray-100/80 dark:hover:bg-gray-800/80 hover:scale-105 active:scale-95 backdrop-blur-sm", + link: "text-primary underline-offset-4 hover:underline hover:text-primary/80", }, size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + default: "h-9 px-[1.425rem] py-2 has-[>svg]:px-[0.95rem]", + sm: "h-8 rounded-lg gap-1.5 px-[0.95rem] has-[>svg]:px-[0.713rem] text-xs", + lg: "h-11 rounded-xl px-[2.375rem] has-[>svg]:px-[1.9rem] text-base", icon: "size-9", "icon-sm": "size-8", - "icon-lg": "size-10", + "icon-lg": "size-11", }, }, defaultVariants: { @@ -34,17 +35,31 @@ const buttonVariants = cva( } ); -const Button = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - } ->(({className, variant, size, asChild = false, ...props}, ref) => { - const Comp = asChild ? Slot : "button"; +interface ButtonProps + extends React.ComponentProps<"button">, + VariantProps { + asChild?: boolean; + loading?: boolean; +} - return ; -}); +const Button = React.forwardRef( + ({className, variant, size, asChild = false, loading = false, children, disabled, ...props}, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + {loading && } + {children} + + ); + } +); Button.displayName = "Button"; diff --git a/mp_web_app/frontend/components/ui/card.tsx b/mp_web_app/frontend/components/ui/card.tsx index 558d04e..4b9dbf7 100644 --- a/mp_web_app/frontend/components/ui/card.tsx +++ b/mp_web_app/frontend/components/ui/card.tsx @@ -1,14 +1,34 @@ import * as React from "react"; +import {cva, type VariantProps} from "class-variance-authority"; import {cn} from "@/lib/utils"; -function Card({className, ...props}: React.ComponentProps<"div">) { +const cardVariants = cva( + "relative bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 text-card-foreground flex flex-col gap-6 rounded-2xl py-6 transition-all duration-500 overflow-hidden group", + { + variants: { + variant: { + default: "border border-gray-200/50 dark:border-gray-700/50 shadow-lg hover:shadow-2xl before:absolute before:inset-0 before:bg-gradient-to-br before:from-primary/5 before:via-transparent before:to-primary/10 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-500 before:pointer-events-none", + elevated: "border border-gray-200/50 dark:border-gray-700/50 shadow-xl hover:shadow-2xl backdrop-blur-sm", + outlined: "border-2 border-gray-300 dark:border-gray-600 shadow-md hover:shadow-xl hover:border-primary/50", + }, + hoverable: { + true: "hover:shadow-2xl hover:-translate-y-2 cursor-pointer after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-gradient-to-r after:from-transparent after:via-primary/50 after:to-transparent after:opacity-0 hover:after:opacity-100 after:transition-opacity after:duration-500 after:pointer-events-none", + false: "", + }, + }, + defaultVariants: { + variant: "default", + hoverable: false, + }, + } +); + +interface CardProps extends React.ComponentProps<"div">, VariantProps {} + +function Card({className, variant, hoverable, ...props}: CardProps) { return ( -
+
); } @@ -17,7 +37,7 @@ function CardHeader({className, ...props}: React.ComponentProps<"div">) {
) { } function CardContent({className, ...props}: React.ComponentProps<"div">) { - return
; + return
; } function CardFooter({className, ...props}: React.ComponentProps<"div">) { diff --git a/mp_web_app/frontend/components/ui/dialog.tsx b/mp_web_app/frontend/components/ui/dialog.tsx index 23d13dc..db2b772 100644 --- a/mp_web_app/frontend/components/ui/dialog.tsx +++ b/mp_web_app/frontend/components/ui/dialog.tsx @@ -22,18 +22,21 @@ function DialogClose({...props}: React.ComponentProps; } -function DialogOverlay({className, ...props}: React.ComponentProps) { - return ( - - ); -} +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; function DialogContent({ className, @@ -49,7 +52,7 @@ function DialogContent({ = ({size = "md", text return (
-
- {text &&

{text}

} +
+ {text &&

{text}

}
); }; diff --git a/mp_web_app/frontend/components/ui/navigation-menu.tsx b/mp_web_app/frontend/components/ui/navigation-menu.tsx index 001adcc..a3291a1 100644 --- a/mp_web_app/frontend/components/ui/navigation-menu.tsx +++ b/mp_web_app/frontend/components/ui/navigation-menu.tsx @@ -42,7 +42,7 @@ function NavigationMenuItem({className, ...props}: React.ComponentProps {children} diff --git a/mp_web_app/frontend/components/ui/table.tsx b/mp_web_app/frontend/components/ui/table.tsx index 6238765..17e1772 100644 --- a/mp_web_app/frontend/components/ui/table.tsx +++ b/mp_web_app/frontend/components/ui/table.tsx @@ -1,17 +1,19 @@ import * as React from "react"; +import {ArrowUpDown, ChevronLeft, ChevronRight} from "lucide-react"; import {cn} from "@/lib/utils"; +import {Button} from "./button"; function Table({className, ...props}: React.ComponentProps<"table">) { return ( -
- +
+
); } 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 ( ); } @@ -56,7 +83,7 @@ function TableCell({className, ...props}: React.ComponentProps<"td">) {
[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} + +
+ ) : ( + children + )} +
[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() {
- {error && ( -
{error}
- )} - {success && ( -
- {success} -
- )} -
setFileType(e.target.value)} className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" - disabled={submitting} + disabled={submitting || userRole === "accountant"} required > - {fileTypeOptions.map((opt) => ( - - ))} + {fileTypeOptions + .filter((opt) => userRole !== "accountant" || opt.value === "accounting") + .map((opt) => ( + + ))} -

Използвайте валидна стойност според FileType в бекенда.

+

+ {userRole === "accountant" + ? "Счетоводителите могат да качват само счетоводни документи." + : "Използвайте валидна стойност според FileType в бекенда."} +

@@ -240,18 +288,53 @@ export default function UploadFile() { disabled={submitting} required /> -
- -
- !submitting && document.getElementById("file")?.click()} > - {file?.name || "Няма избран файл"} - +
+ + + + {file ? ( +
+

{file.name}

+

+ {file.size >= 1024 * 1024 + ? `${(file.size / 1024 / 1024).toFixed(2)} MB` + : `${(file.size / 1024).toFixed(2)} KB`} +

+

Кликни или пусни файл за промяна

+
+ ) : ( +
+

+ {isDragging ? "Пусни файла тук" : "Кликни или пусни файл"} +

+
+ )} +
+
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 */}
-
-

© 2025

-
+ {/* 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 && ( -
-

{error}

-
- )} +
+ {loading && ( +
+ +

Зареждане на галерията...

+
+ )} - {!loading && !error && images.length === 0 && ( -
-

Няма налични снимки

-
- )} + {error && ( +
+

{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); + } + }} + /> +
+
+

{image.image_name}

+
+
+
+
+ ) : 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() {
- Меню + Меню
-
); return ( -
+ <> {/* Desktop Navigation */} {!isMobile && ( -
+
- + {NAV_LINKS.map((link) => { if (!link.dropdown) { return ( - {link.label} + + {link.label} + ); @@ -321,11 +390,30 @@ export function Navigation() { } // Role-based filtering for Documents - const documentsItems = isDocuments - ? isAdmin || isBoardOrControl - ? link.dropdown - : link.dropdown.filter((item: any) => item.to === "/governing-documents" || item.to === "/forms") - : filterDropdown(link.dropdown); + let documentsItems = link.dropdown; + if (isDocuments) { + if (isAdmin || isBoardOrControl) { + // Admin, Board, Control: see all documents + documentsItems = link.dropdown; + } else if (isAccountant) { + // Accountant: see ONLY accounting documents + documentsItems = link.dropdown.filter((item: any) => + item.to === "/accounting-documents" + ); + } else { + // Regular users: see all except "Счетоводні документи" (accounting) + documentsItems = link.dropdown.filter((item: any) => + item.to === "/governing-documents" || + item.to === "/forms" || + item.to === "/minutes" || + item.to === "/transcripts" || + item.to === "/mydocuments" || + item.to === "/others" + ); + } + } else { + documentsItems = filterDropdown(link.dropdown); + } const itemsToRender = isDocuments ? documentsItems : link.dropdown; @@ -335,19 +423,24 @@ export function Navigation() { return ( - {link.label} + + {link.label} + -
    -
  • - {itemsToRender.map((item: any) => ( - - -
    {item.label}
    -
    {item.description}
    +
      + {itemsToRender.map((item: any) => ( +
    • + + +
      {item.label}
      +
      {item.description}
      - ))} -
    • + + ))}
    @@ -357,34 +450,58 @@ export function Navigation() { {isLoggedIn && isAdmin && ( <> - + + + - + + + )} + {/* Accountant upload action for desktop */} + {isLoggedIn && isAccountant && ( + + + + + + )} {/* Auth section for desktop */} {!isLoggedIn ? ( - Вход + + Вход + -
      +
      • - -
        Влез
        -
        Влезе в своя акаунт
        + +
        Влез
        +
        Влезе в своя акаунт
        +
      • +
      • - -
        Създай
        -
        Създай акаунт ако си член на ГПК
        + +
        Създай
        +
        Създай акаунт ако си член на ГПК
      • @@ -393,7 +510,10 @@ export function Navigation() { ) : ( - @@ -405,25 +525,21 @@ export function Navigation() { {/* Hamburger Icon for Mobile */} {isMobile && ( -
        +
        )} {/* Mobile Menu Overlay with animation and scrollability */} {showMobileMenu && mobileMenu} - -
        - -
        -
        + ); } diff --git a/mp_web_app/frontend/pages/Products.tsx b/mp_web_app/frontend/pages/Products.tsx index 4858bfa..fa58562 100644 --- a/mp_web_app/frontend/pages/Products.tsx +++ b/mp_web_app/frontend/pages/Products.tsx @@ -1,6 +1,22 @@ - import {ProductsTable} from "@/components/products-table"; export default function Products() { - return ; + return ( +
        + {/* Hero Section */} +
        +
        +
        +
        +

        + Продукти +

        +
        +
        +
        + + {/* Content */} + +
        + ); } diff --git a/mp_web_app/frontend/pages/UploadFile.tsx b/mp_web_app/frontend/pages/UploadFile.tsx index 030c90e..ee5907c 100644 --- a/mp_web_app/frontend/pages/UploadFile.tsx +++ b/mp_web_app/frontend/pages/UploadFile.tsx @@ -2,8 +2,8 @@ import UploadFile from "@/components/upload-file"; export default function Upload() { return ( -
        -
        +
        +
        diff --git a/mp_web_app/frontend/pages/about-us/Board.tsx b/mp_web_app/frontend/pages/about-us/Board.tsx index d7541a7..147f1ed 100644 --- a/mp_web_app/frontend/pages/about-us/Board.tsx +++ b/mp_web_app/frontend/pages/about-us/Board.tsx @@ -1,39 +1,13 @@ -import {useEffect, useState} from "react"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; import {Mail, Phone, User as UserIcon} from "lucide-react"; -import apiClient from "@/context/apiClient"; +import {useBoardMembers} from "@/hooks/useUsers"; import {useAuth} from "@/context/AuthContext"; -interface User { - id: string; - first_name: string; - last_name: string; - email: string; - phone: string; - role: string; -} - export default function Board() { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); + const {data: members = [], isLoading: loading} = useBoardMembers(); const {isLoggedIn} = useAuth(); - useEffect(() => { - const fetchMembers = async () => { - try { - const response = await apiClient.get("users/board"); - setMembers(response.data || []); - } catch (error) { - console.error("Failed to fetch board members:", error); - } finally { - setLoading(false); - } - }; - - fetchMembers(); - }, []); - if (loading) { return (
        @@ -43,68 +17,87 @@ export default function Board() { } return ( -
        +
        + {/* Hero Section */} +
        +
        +
        +
        +

        + Управителен съвет +

        +
        +
        +
        - +
        + - Членове на управителния съвет + Членове на управителния съвет ({members.length}) - + {members.length === 0 ? (

        Няма налични данни

        ) : ( -
        - +
        +
        - + +
        Име
        - -
        - - Имейл -
        -
        {isLoggedIn && ( - -
        - - Телефон -
        -
        + <> + +
        + + Имейл +
        +
        + +
        + + Телефон +
        +
        + )}
        - {members.map((member) => ( + {members.map((member, index) => ( - + {index + 1} + {member.first_name} {member.last_name} - - - {member.email} - - {isLoggedIn && ( - - - {member.phone} - - + <> + + + {member.email} + + + + + {member.phone || "-"} + + + )} ))}
        -
        +
        )} -
        +
        +
        ); } diff --git a/mp_web_app/frontend/pages/about-us/Control.tsx b/mp_web_app/frontend/pages/about-us/Control.tsx index 92d6bd5..68ed2d4 100644 --- a/mp_web_app/frontend/pages/about-us/Control.tsx +++ b/mp_web_app/frontend/pages/about-us/Control.tsx @@ -1,39 +1,13 @@ -import {useEffect, useState} from "react"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; import {Mail, Phone, User as UserIcon} from "lucide-react"; -import apiClient from "@/context/apiClient"; +import {useControlMembers} from "@/hooks/useUsers"; import {useAuth} from "@/context/AuthContext"; -interface User { - id: string; - first_name: string; - last_name: string; - email: string; - phone: string; - role: string; -} - export default function Control() { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); + const {data: members = [], isLoading: loading} = useControlMembers(); const {isLoggedIn} = useAuth(); - useEffect(() => { - const fetchMembers = async () => { - try { - const response = await apiClient.get("users/control"); - setMembers(response.data || []); - } catch (error) { - console.error("Failed to fetch control members:", error); - } finally { - setLoading(false); - } - }; - - fetchMembers(); - }, []); - if (loading) { return (
        @@ -43,68 +17,87 @@ export default function Control() { } return ( -
        +
        + {/* Hero Section */} +
        +
        +
        +
        +

        + Контролен съвет +

        +
        +
        +
        - +
        + - Членове на контролния съвет + Членове на контролния съвет ({members.length}) - + {members.length === 0 ? (

        Няма налични данни

        ) : ( -
        - +
        +
        - + +
        Име
        - -
        - - Имейл -
        -
        {isLoggedIn && ( - -
        - - Телефон -
        -
        + <> + +
        + + Имейл +
        +
        + +
        + + Телефон +
        +
        + )}
        - {members.map((member) => ( + {members.map((member, index) => ( - + {index + 1} + {member.first_name} {member.last_name} - - - {member.email} - - {isLoggedIn && ( - - - {member.phone} - - + <> + + + {member.email} + + + + + {member.phone || "-"} + + + )} ))}
        -
        +
        )} -
        +
        +
        ); } diff --git a/mp_web_app/frontend/pages/admin/AdminPanel.tsx b/mp_web_app/frontend/pages/admin/AdminPanel.tsx index a1c2e61..def18e7 100644 --- a/mp_web_app/frontend/pages/admin/AdminPanel.tsx +++ b/mp_web_app/frontend/pages/admin/AdminPanel.tsx @@ -1,6 +1,7 @@ import {useEffect, useState} from "react"; import {useNavigate} from "react-router-dom"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import {getAccessToken} from "@/context/tokenStore"; import {getUserRole} from "@/context/jwt"; import NewsManagement from "./NewsManagement"; @@ -8,11 +9,29 @@ import UserManagement from "./UserManagement"; import ProductsManagement from "./ProductsManagement"; import DocumentsManagement from "./DocumentsManagement"; import GalleryManagement from "./GalleryManagement"; +import MembersManagement from "./MembersManagement"; +import EmailsManagement from "./EmailsManagement"; export default function AdminPanel() { const navigate = useNavigate(); const [isAdmin, setIsAdmin] = useState(false); const [loading, setLoading] = useState(true); + // Load active tab from localStorage or default to "news" + const [activeTab, setActiveTab] = useState(() => { + return localStorage.getItem("adminActiveTab") || "news"; + }); + const [isMobile, setIsMobile] = useState(window.innerWidth < 1200); + + useEffect(() => { + const handleResize = () => setIsMobile(window.innerWidth < 1200); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + // Save active tab to localStorage whenever it changes + useEffect(() => { + localStorage.setItem("adminActiveTab", activeTab); + }, [activeTab]); useEffect(() => { const token = getAccessToken(); @@ -39,38 +58,90 @@ export default function AdminPanel() { } return ( -
        -

        Административен панел

        - - - - Новини - Потребители - Продукти - Документи - Галерия - - - - - - - - - - - - - - - - - - - - - - +
        + {/* Hero Section */} +
        +
        +
        +
        +

        + Административен панел +

        +
        +
        +
        + +
        + + {isMobile ? ( +
        + + +
        + {activeTab === "news" && } + {activeTab === "users" && } + {activeTab === "products" && } + {activeTab === "documents" && } + {activeTab === "gallery" && } + {activeTab === "members" && } + {activeTab === "emails" && } +
        +
        + ) : ( + + + Новини + Потребители + Продукти + Документи + Галерия + Членове + Мейл + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} +
        ); } diff --git a/mp_web_app/frontend/pages/admin/DocumentsManagement.tsx b/mp_web_app/frontend/pages/admin/DocumentsManagement.tsx index 674a315..33fed28 100644 --- a/mp_web_app/frontend/pages/admin/DocumentsManagement.tsx +++ b/mp_web_app/frontend/pages/admin/DocumentsManagement.tsx @@ -3,9 +3,9 @@ import {AdminLayout} from "@/components/admin-layout"; import {Button} from "@/components/ui/button"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; -import {Checkbox} from "@/components/ui/checkbox"; import {ConfirmDialog} from "@/components/confirm-dialog"; import {useToast} from "@/components/ui/use-toast"; +import {LoadingSpinner} from "@/components/ui/loading-spinner"; import apiClient from "@/context/apiClient"; interface FileMetadata { @@ -13,6 +13,7 @@ interface FileMetadata { file_name: string; file_type: string; uploaded_by: string; + uploaded_by_name?: string; created_at: string; } @@ -31,8 +32,8 @@ export default function DocumentsManagement() { const [files, setFiles] = useState([]); const [filteredFiles, setFilteredFiles] = useState([]); const [loading, setLoading] = useState(true); - const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); const [selectedFileType, setSelectedFileType] = useState("all"); const {toast} = useToast(); @@ -89,48 +90,30 @@ export default function DocumentsManagement() { } else { setFilteredFiles(files.filter((f) => f.file_type === selectedFileType)); } - // Clear selection when filter changes - setSelectedFiles(new Set()); }, [selectedFileType, files]); - const toggleFileSelection = (fileId: string) => { - const newSelection = new Set(selectedFiles); - if (newSelection.has(fileId)) { - newSelection.delete(fileId); - } else { - newSelection.add(fileId); - } - setSelectedFiles(newSelection); - }; - - const toggleAllSelection = () => { - if (selectedFiles.size === filteredFiles.length) { - setSelectedFiles(new Set()); - } else { - setSelectedFiles(new Set(filteredFiles.map((f) => f.id))); - } + const openDeleteDialog = (file: FileMetadata) => { + setSelectedFile(file); + setDeleteDialogOpen(true); }; const handleDelete = async () => { - try { - const filesToDelete = files.filter((f) => selectedFiles.has(f.id)); + if (!selectedFile) return; - // Delete each file (backend will delete both S3 file and DynamoDB metadata) - for (const file of filesToDelete) { - await apiClient.delete(`files/delete/${file.id}`); - } + try { + await apiClient.delete(`files/delete/${selectedFile.id}`); toast({ title: "Успех", - description: `${filesToDelete.length} документа са изтрити успешно`, + description: "Документът е изтрит успешно", }); setDeleteDialogOpen(false); - setSelectedFiles(new Set()); + setSelectedFile(null); fetchFiles(); } catch (err: any) { toast({ title: "Грешка", - description: err.response?.data?.detail || "Неуспешно изтриване на документите", + description: err.response?.data?.detail || "Неуспешно изтриване на документа", variant: "destructive", }); } @@ -144,74 +127,71 @@ export default function DocumentsManagement() { return (
        -
        -
        -

        Списък с документи

        - - - ({filteredFiles.length} {filteredFiles.length === 1 ? "документ" : "документа"}) - -
        - {selectedFiles.size > 0 && ( - - )} +
        +

        Списък с документи

        + + + ({filteredFiles.length} {filteredFiles.length === 1 ? "документ" : "документа"}) +
        {loading ? ( -

        Зареждане...

        + ) : filteredFiles.length === 0 ? (

        Няма налични документи{selectedFileType !== "all" ? " от този тип" : ""}

        ) : ( -
        - - - - - 0} - onCheckedChange={toggleAllSelection} - /> - - Име на файл - Тип - Качен от - Дата +
        +
        + + + + Име на файл + Тип + Качен от + Дата + Действия + + + + {filteredFiles.map((file, index) => ( + + {index + 1} + {file.file_name} + + {getFileTypeLabel(file.file_type)} + + {file.uploaded_by_name || file.uploaded_by} + {new Date(file.created_at).toLocaleDateString("bg-BG", { + day: '2-digit', + month: '2-digit', + year: 'numeric' + })} + + + - - - {filteredFiles.map((file) => ( - - - toggleFileSelection(file.id)} - /> - - {file.file_name} - - {getFileTypeLabel(file.file_type)} - - {file.uploaded_by} - {new Date(file.created_at).toLocaleDateString("bg-BG")} - - ))} - -
        + ))} + +
)} @@ -219,8 +199,8 @@ export default function DocumentsManagement() { + +
+
+ + + +
+

В разработка

+

+ Функционалността за управление на мейли е в процес на разработка и скоро ще бъде достъпна. +

+
+
+ + ); +} diff --git a/mp_web_app/frontend/pages/admin/GalleryManagement.tsx b/mp_web_app/frontend/pages/admin/GalleryManagement.tsx index 6916e67..98065df 100644 --- a/mp_web_app/frontend/pages/admin/GalleryManagement.tsx +++ b/mp_web_app/frontend/pages/admin/GalleryManagement.tsx @@ -6,7 +6,8 @@ import {Card} from "@/components/ui/card"; import {ConfirmDialog} from "@/components/confirm-dialog"; import {useToast} from "@/components/ui/use-toast"; import apiClient from "@/context/apiClient"; -import {X} from "lucide-react"; +import {Trash2} from "lucide-react"; +import {extractApiErrorDetails} from "@/lib/errorUtils"; interface GalleryImage { id: string; @@ -26,6 +27,7 @@ export default function GalleryManagement() { const [selectedImage, setSelectedImage] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [imageName, setImageName] = useState(""); + const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const {toast} = useToast(); @@ -46,9 +48,10 @@ export default function GalleryManagement() { }); setImageUrls(urls); } catch (err: any) { + const errorMessage = extractApiErrorDetails(err.response?.data || err); toast({ title: "Грешка", - description: err.response?.data?.detail || "Неуспешно зареждане на галерията", + description: errorMessage || "Неуспешно зареждане на галерията", variant: "destructive", }); } finally { @@ -63,11 +66,87 @@ export default function GalleryManagement() { const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + + if (!allowedTypes.includes(file.type)) { + toast({ + title: "Грешка", + description: "Невалиден формат. Разрешени формати: JPG, PNG, GIF, WEBP", + variant: "destructive", + }); + e.target.value = ""; // Reset input + return; + } + + // Check file size + const maxSize = 15 * 1024 * 1024; // 15MB + if (file.size > maxSize) { + toast({ + title: "Грешка", + description: `Файлът е твърде голям. Максимален размер: 15MB. Вашият файл: ${(file.size / 1024 / 1024).toFixed(2)}MB`, + variant: "destructive", + }); + e.target.value = ""; // Reset input + return; + } + setSelectedFile(file); setImageName(file.name.split(".")[0]); } }; + // 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) { + const file = files[0]; + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + + if (file.type.startsWith('image/') && allowedTypes.includes(file.type)) { + // Check file size + const maxSize = 15 * 1024 * 1024; // 15MB + if (file.size > maxSize) { + toast({ + title: "Грешка", + description: `Файлът е твърде голям. Максимален размер: 15MB. Вашият файл: ${(file.size / 1024 / 1024).toFixed(2)}MB`, + variant: "destructive", + }); + return; + } + + setSelectedFile(file); + setImageName(file.name.split(".")[0]); + } else { + toast({ + title: "Грешка", + description: "Невалиден формат. Разрешени формати: JPG, PNG, GIF, WEBP", + variant: "destructive", + }); + } + } + }; + const handleUpload = async () => { if (!selectedFile) { toast({ @@ -78,6 +157,28 @@ export default function GalleryManagement() { return; } + // Validate file size on frontend (15MB limit) + const maxSize = 15 * 1024 * 1024; // 15MB in bytes + if (selectedFile.size > maxSize) { + toast({ + title: "Грешка", + description: `Файлът е твърде голям. Максимален размер: 15MB. Вашият файл: ${(selectedFile.size / 1024 / 1024).toFixed(2)}MB`, + variant: "destructive", + }); + return; + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(selectedFile.type)) { + toast({ + title: "Грешка", + description: "Невалиден формат на снимката. Разрешени формати: JPG, PNG, GIF, WEBP", + variant: "destructive", + }); + return; + } + try { setUploading(true); @@ -105,13 +206,16 @@ export default function GalleryManagement() { fileInputRef.current.value = ""; } - fetchGallery(); + // Refresh gallery + await fetchGallery(); } catch (err: any) { + const errorMessage = extractApiErrorDetails(err.response?.data || err); toast({ - title: "Грешка", - description: err.response?.data?.detail || "Неуспешно качване на снимката", + title: "Грешка при качване", + description: errorMessage || "Неуспешно качване на снимката", variant: "destructive", }); + console.error("Upload error:", err); } finally { setUploading(false); } @@ -135,9 +239,10 @@ export default function GalleryManagement() { setSelectedImage(null); fetchGallery(); } catch (err: any) { + const errorMessage = extractApiErrorDetails(err.response?.data || err); toast({ title: "Грешка", - description: err.response?.data?.detail || "Неуспешно изтриване на снимката", + description: errorMessage || "Неуспешно изтриване на снимката", variant: "destructive", }); } @@ -156,9 +261,62 @@ export default function GalleryManagement() { }} className="space-y-4" > -
- - + {/* Hidden file input */} + + + {/* Drag and drop zone */} +
!uploading && fileInputRef.current?.click()} + > +
+ + + + {selectedFile ? ( +
+

{selectedFile.name}

+

+ {selectedFile.size >= 1024 * 1024 + ? `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB` + : `${(selectedFile.size / 1024).toFixed(2)} KB`} +

+

Кликни или пусни снимка за промяна

+
+ ) : ( +
+

+ {isDragging ? "Пусни снимката тук" : "Кликни или пусни снимка"} +

+

PNG, JPG, GIF до 15MB

+
+ )} +
{selectedFile && ( @@ -169,6 +327,7 @@ export default function GalleryManagement() { onChange={(e) => setImageName(e.target.value)} placeholder="Въведете име" disabled={uploading} + className="mt-2" />
)} @@ -197,11 +356,11 @@ export default function GalleryManagement() { {image.image_name} ); diff --git a/mp_web_app/frontend/pages/admin/MembersManagement.tsx b/mp_web_app/frontend/pages/admin/MembersManagement.tsx new file mode 100644 index 0000000..603ebb2 --- /dev/null +++ b/mp_web_app/frontend/pages/admin/MembersManagement.tsx @@ -0,0 +1,179 @@ +import React, {useState, useRef} from "react"; +import {AdminLayout} from "@/components/admin-layout"; +import {Button} from "@/components/ui/button"; +import {Card} from "@/components/ui/card"; +import {useToast} from "@/components/ui/use-toast"; +import apiClient from "@/context/apiClient"; + +export default function MembersManagement() { + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + const {toast} = useToast(); + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + } + }; + + // 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) { + setSelectedFile(files[0]); + } + }; + + const handleUpload = async () => { + if (!selectedFile) { + toast({ + title: "Грешка", + description: "Моля изберете файл", + variant: "destructive", + }); + return; + } + + try { + setUploading(true); + + const formData = new FormData(); + formData.append("file", selectedFile); + + await apiClient.post("members/sync_members", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + toast({ + title: "Успех", + description: "Членовете са синхронизирани успешно", + }); + + // Reset form + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } catch (err: any) { + toast({ + title: "Грешка", + description: err.response?.data?.detail || "Неуспешна синхронизация на членовете", + variant: "destructive", + }); + } finally { + setUploading(false); + } + }; + + return ( + +
+ +

Синхронизирай членове

+ { + e.preventDefault(); + handleUpload(); + }} + className="space-y-4" + > + {/* Hidden file input */} + + + {/* Drag and drop zone */} +
!uploading && fileInputRef.current?.click()} + > +
+ + + + {selectedFile ? ( +
+

{selectedFile.name}

+

+ {selectedFile.size >= 1024 * 1024 + ? `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB` + : `${(selectedFile.size / 1024).toFixed(2)} KB`} +

+

Кликни или пусни файл за промяна

+
+ ) : ( +
+

+ {isDragging ? "Пусни файла тук" : "Кликни или пусни файл"} +

+

Приема се само предварително подготвен файл с предварително зададена структура в .csv формат

+
+ )} +
+
+ + + +
+ + +

Информация

+

+ Качете файл с данни за членовете, за да синхронизирате информацията в системата. + Файлът трябва да съдържа необходимите полета за членовете. +

+
+
+
+ ); +} diff --git a/mp_web_app/frontend/pages/admin/NewsManagement.tsx b/mp_web_app/frontend/pages/admin/NewsManagement.tsx index 8909202..6bacdf0 100644 --- a/mp_web_app/frontend/pages/admin/NewsManagement.tsx +++ b/mp_web_app/frontend/pages/admin/NewsManagement.tsx @@ -13,9 +13,10 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import {Badge} from "@/components/ui/badge"; import {ConfirmDialog} from "@/components/confirm-dialog"; import {useToast} from "@/components/ui/use-toast"; +import {LoadingSpinner} from "@/components/ui/loading-spinner"; +import {Lock, Globe} from "lucide-react"; import apiClient from "@/context/apiClient"; import {getAccessToken} from "@/context/tokenStore"; @@ -146,7 +147,7 @@ export default function NewsManagement() {
-

Списък с новини

+

Списък с новини ({news.length})

@@ -198,30 +199,41 @@ export default function NewsManagement() {
{loading ? ( -

Зареждане...

+ ) : news.length === 0 ? (

Няма налични новини

) : ( - +
+
- Заглавие - Тип - Дата - Действия + + Заглавие + Тип + Дата + Действия - {news.map((item) => ( + {news.map((item, index) => ( - {item.title} - - - {item.news_type === "private" ? "За членове" : "Обществена"} - + {index + 1} + {item.title} + + {item.news_type === "private" ? ( +
+ + За членове +
+ ) : ( +
+ + Обществена +
+ )}
- {new Date(item.created_at).toLocaleDateString("bg-BG")} - + {new Date(item.created_at).toLocaleDateString("bg-BG")} +
+
)} {/* Edit Dialog */} diff --git a/mp_web_app/frontend/pages/admin/ProductsManagement.tsx b/mp_web_app/frontend/pages/admin/ProductsManagement.tsx index 65a0303..0866f1f 100644 --- a/mp_web_app/frontend/pages/admin/ProductsManagement.tsx +++ b/mp_web_app/frontend/pages/admin/ProductsManagement.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/dialog"; import {ConfirmDialog} from "@/components/confirm-dialog"; import {useToast} from "@/components/ui/use-toast"; +import {LoadingSpinner} from "@/components/ui/loading-spinner"; import apiClient from "@/context/apiClient"; import {extractApiErrorDetails} from "@/lib/errorUtils"; @@ -106,13 +107,24 @@ export default function ProductsManagement() { const handleEdit = async () => { if (!selectedProduct) return; + + // Validate name is not empty + if (!formData.name || formData.name.trim() === "") { + toast({ + title: "Грешка", + description: "Името на продукта е задължително", + variant: "destructive", + }); + return; + } + try { const payload = { - name: formData.name || null, - width: formData.width ? parseFloat(formData.width) : null, - height: formData.height ? parseFloat(formData.height) : null, - length: formData.length ? parseFloat(formData.length) : null, - description: formData.description || null, + name: formData.name.trim(), + width: formData.width && formData.width.trim() !== "" ? parseFloat(formData.width) : null, + height: formData.height && formData.height.trim() !== "" ? parseFloat(formData.height) : null, + length: formData.length && formData.length.trim() !== "" ? parseFloat(formData.length) : null, + description: formData.description && formData.description.trim() !== "" ? formData.description.trim() : null, }; await apiClient.put(`products/update/${selectedProduct.id}`, payload); @@ -178,7 +190,7 @@ export default function ProductsManagement() {
-

Списък с продукти

+

Списък с продукти ({products.length})

@@ -247,30 +259,33 @@ export default function ProductsManagement() {
{loading ? ( -

Зареждане...

+ ) : products.length === 0 ? (

Няма налични продукти

) : ( - +
+
- Име - Дължина (см) - Ширина (см) - Височина (см) - Описание - Действия + + Име + Дължина (см) + Ширина (см) + Височина (см) + Описание + Действия - {products.map((product) => ( + {products.map((product, index) => ( - {product.name} - {product.length ?? "-"} - {product.width ?? "-"} - {product.height ?? "-"} - {product.description || "-"} - + {index + 1} + {product.name} + {product.length ?? "-"} + {product.width ?? "-"} + {product.height ?? "-"} + {product.description || "-"} +
+
)} {/* Edit Dialog */} diff --git a/mp_web_app/frontend/pages/admin/UserManagement.tsx b/mp_web_app/frontend/pages/admin/UserManagement.tsx index c03aad1..6a7c96e 100644 --- a/mp_web_app/frontend/pages/admin/UserManagement.tsx +++ b/mp_web_app/frontend/pages/admin/UserManagement.tsx @@ -7,6 +7,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/c import {Switch} from "@/components/ui/switch"; import {ConfirmDialog} from "@/components/confirm-dialog"; import {useToast} from "@/components/ui/use-toast"; +import {LoadingSpinner} from "@/components/ui/loading-spinner"; import apiClient from "@/context/apiClient"; interface User { @@ -25,6 +26,7 @@ const roleTranslations: Record = { regular: "Обикновен", board: "УС", control: "КС", + accountant: "Счетоводител", admin: "Админ", }; @@ -49,12 +51,15 @@ export default function UserManagement() { const response = await apiClient.get("users/list"); // Force a new array reference to trigger React re-render setUsers([...(response.data || [])]); - } catch (err) { - toast({ - title: "Грешка", - description: "Неуспешно зареждане на потребителите", - variant: "destructive", - }); + } catch (err: any) { + // Don't show error toast for auth refresh errors (they're handled automatically) + if (!(err as any)?.isAuthRefresh) { + toast({ + title: "Грешка", + description: "Неуспешно зареждане на потребителите", + variant: "destructive", + }); + } } finally { setLoading(false); } @@ -123,30 +128,38 @@ export default function UserManagement() { return (
+
+

Списък с потребители ({users.length})

+
{loading ? ( -

Зареждане...

+ ) : ( - +
+
- Име - Email - Роля - Активен - Абониран - Действия + + Име + Email + Телефон + Роля + Активен + Абониран + Действия - {users.map((user) => ( + {users.map((user, index) => ( - + {index + 1} + {user.first_name} {user.last_name} - {user.email} - {roleTranslations[user.role] || user.role} - {user.active ? "Да" : "Не"} - {user.subscribed ? "Да" : "Не"} + {user.email} + {user.phone || "-"} + {roleTranslations[user.role] || user.role} + {user.active ? "Да" : "Не"} + {user.subscribed ? "Да" : "Не"}
+
)} {/* Edit Dialog */} @@ -191,6 +205,7 @@ export default function UserManagement() { Обикновен УС КС + Счетоводител Администратор diff --git a/mp_web_app/frontend/pages/lists/CooperativeMembers.tsx b/mp_web_app/frontend/pages/lists/CooperativeMembers.tsx index 21f23b9..302998d 100644 --- a/mp_web_app/frontend/pages/lists/CooperativeMembers.tsx +++ b/mp_web_app/frontend/pages/lists/CooperativeMembers.tsx @@ -1,83 +1,129 @@ -import {useEffect, useState} from "react"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; -import {User as UserIcon} from "lucide-react"; -import apiClient from "@/context/apiClient"; - -interface Member { - first_name: string; - last_name: string; - proxy: boolean; -} +import {User as UserIcon, Mail, Phone, CheckCircle, XCircle} from "lucide-react"; +import {LoadingSpinner} from "@/components/ui/loading-spinner"; +import {useMembers} from "@/hooks/useMembers"; +import {useAuth} from "@/context/AuthContext"; export default function CooperativeMembers() { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchMembers = async () => { - try { - const response = await apiClient.get("members/list", { - params: {proxy_only: false}, - }); - setMembers(response.data || []); - } catch (error) { - console.error("Failed to fetch cooperative members:", error); - } finally { - setLoading(false); - } - }; - - fetchMembers(); - }, []); + const {data: members = [], isLoading: loading} = useMembers({ proxy_only: false }); + const {user} = useAuth(); + const isAdmin = user?.role === "admin"; + const isBoardOrControl = user?.role === "board" || user?.role === "control"; + const canSeeContactInfo = isAdmin || isBoardOrControl; if (loading) { return (
-

Зареждане...

+
); } return ( -
+
+ {/* Hero Section */} +
+
+
+
+

+ Член кооператори +

+
+
+
- +
+ - Списък на член кооператорите + Списък на член кооператорите ({members.length}) - + {members.length === 0 ? (

Няма налични данни

) : ( -
- +
+
- - + +
Име
- Фамилия + Фамилия + {canSeeContactInfo && ( + <> + +
+ + Имейл +
+
+ +
+ + Телефон +
+
+ + )} + {isAdmin && ( + <> + Код + Използван + + )}
{members.map((member, index) => ( - {index + 1} - {member.first_name} - {member.last_name} + {index + 1} + {member.first_name} + {member.last_name} + {canSeeContactInfo && ( + <> + {member.email || "-"} + {member.phone || "-"} + + )} + {isAdmin && ( + <> + + {member.member_code ? ( + + {member.member_code} + + ) : ( + "-" + )} + + + {member.member_code ? ( + !member.member_code_valid ? ( + + ) : ( + + ) + ) : ( + "-" + )} + + + )} ))}
-
+
)} -
+
+
); } diff --git a/mp_web_app/frontend/pages/lists/Proxies.tsx b/mp_web_app/frontend/pages/lists/Proxies.tsx index fef117a..318a455 100644 --- a/mp_web_app/frontend/pages/lists/Proxies.tsx +++ b/mp_web_app/frontend/pages/lists/Proxies.tsx @@ -1,68 +1,132 @@ -import {useEffect, useState} from "react"; +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; -import apiClient from "@/context/apiClient"; - -interface Member { - first_name: string; - last_name: string; - proxy: boolean; -} +import {User as UserIcon, Mail, Phone, CheckCircle, XCircle} from "lucide-react"; +import {LoadingSpinner} from "@/components/ui/loading-spinner"; +import {useMembers} from "@/hooks/useMembers"; +import {useAuth} from "@/context/AuthContext"; export default function Proxies() { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchProxies = async () => { - try { - setLoading(true); - // Fetch members with proxy_only=true filter - const response = await apiClient.get("members/list", { - params: {proxy_only: true}, - }); - setMembers(response.data || []); - } catch (err: any) { - setError(err.response?.data?.detail || "Неуспешно зареждане на пълномощниците"); - } finally { - setLoading(false); - } - }; + const {data: members = [], isLoading: loading, error: queryError} = useMembers({ proxy_only: true }); + const error = queryError ? "Неуспешно зареждане на пълномощниците" : null; + const {isLoggedIn, user} = useAuth(); + const isAdmin = user?.role === "admin"; + const isBoardOrControl = user?.role === "board" || user?.role === "control"; + const canSeePhone = isAdmin || isBoardOrControl; - fetchProxies(); - }, []); + if (loading) { + return ( +
+ +
+ ); + } return ( -
- - {loading ? ( -

Зареждане...

- ) : error ? ( -

{error}

- ) : members.length === 0 ? ( -

Няма налични пълномощници

- ) : ( -
- - - - - Име - Фамилия - - - - {members.map((member, index) => ( - - {index + 1} - {member.first_name} - {member.last_name} - - ))} - -
+
+ {/* Hero Section */} +
+
+
+
+

+ Пълномощници +

+
- )} -
+
+ +
+ + + Списък на пълномощниците ({members.length}) + + + {error ? ( +

{error}

+ ) : members.length === 0 ? ( +

Няма налични пълномощници

+ ) : ( +
+ + + + + +
+ + Име +
+
+ Фамилия + {isLoggedIn && ( + +
+ + Имейл +
+
+ )} + {canSeePhone && ( + +
+ + Телефон +
+
+ )} + {isAdmin && ( + <> + Код + Използван + + )} +
+
+ + {members.map((member, index) => ( + + {index + 1} + {member.first_name} + {member.last_name} + {isLoggedIn && ( + {member.email || "-"} + )} + {canSeePhone && ( + {member.phone || "-"} + )} + {isAdmin && ( + <> + + {member.member_code ? ( + + {member.member_code} + + ) : ( + "-" + )} + + + {member.member_code ? ( + !member.member_code_valid ? ( + + ) : ( + + ) + ) : ( + "-" + )} + + + )} + + ))} + +
+
+ )} +
+
+
+
); } diff --git a/mp_web_app/frontend/pnpm-lock.yaml b/mp_web_app/frontend/pnpm-lock.yaml index 80a0932..2d847fd 100644 --- a/mp_web_app/frontend/pnpm-lock.yaml +++ b/mp_web_app/frontend/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.4 version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.90.9 + version: 5.90.9(react@18.3.1) axios: specifier: ^1.7.9 version: 1.13.2 @@ -1077,6 +1080,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.9': + resolution: {integrity: sha512-UFOCQzi6pRGeVTVlPNwNdnAvT35zugcIydqjvFUzG62dvz2iVjElmNp/hJkUoM5eqbUPfSU/GJIr/wbvD8bTUw==} + + '@tanstack/react-query@5.90.9': + resolution: {integrity: sha512-Zke2AaXiaSfnG8jqPZR52m8SsclKT2d9//AgE/QIzyNvbpj/Q2ln+FsZjb1j69bJZUouBvX2tg9PHirkTm8arw==} + peerDependencies: + react: ^18 || ^19 + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2853,6 +2864,13 @@ snapshots: tailwindcss: 4.1.17 vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2) + '@tanstack/query-core@5.90.9': {} + + '@tanstack/react-query@5.90.9(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.90.9 + react: 18.3.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 diff --git a/mp_web_app/frontend/src/App.tsx b/mp_web_app/frontend/src/App.tsx index 75df971..cf2dd00 100644 --- a/mp_web_app/frontend/src/App.tsx +++ b/mp_web_app/frontend/src/App.tsx @@ -1,10 +1,11 @@ import {lazy} from "react"; import {AuthProvider} from "@/context/AuthContext"; -import Navigation from "@/pages/Navigation"; import Base from "@/pages/Base"; import {Route, Routes} from "react-router-dom"; import PageLoadingWrapper from "@/components/page-loading-wrapper"; import {Toaster} from "@/components/ui/toaster"; +import {QueryClientProvider} from "@tanstack/react-query"; +import {queryClient} from "@/lib/queryClient"; // Regular imports for pages that don't need API calls import Home from "@/pages/Home"; @@ -34,11 +35,11 @@ const AdminPanel = lazy(() => import("@/pages/admin/AdminPanel")); function App() { return ( - - - + + + + }> - }> } /> } /> } /> @@ -151,10 +152,10 @@ function App() { } /> } /> } /> - - + + ); } diff --git a/mp_web_app/frontend/src/styles/global.css b/mp_web_app/frontend/src/styles/global.css index 45e9a76..ec45872 100644 --- a/mp_web_app/frontend/src/styles/global.css +++ b/mp_web_app/frontend/src/styles/global.css @@ -4,31 +4,52 @@ @custom-variant dark (&:is(.dark *)); :root { + /* Brand Colors - Company Green & White */ --background: oklch(1 0 0); --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.5889 0.145 154.56); + --primary: oklch(0.5889 0.145 154.56); /* Company Green */ --primary-foreground: oklch(0.985 0 0); + --primary-hover: oklch(0.52 0.15 154.56); /* Slightly darker green for hover */ --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); + --accent: oklch(0.65 0.12 154.56); /* Lighter green accent */ + --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.65 0.15 145); /* Success green */ + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.75 0.15 85); /* Warning amber */ + --warning-foreground: oklch(0.145 0 0); + --info: oklch(0.60 0.12 220); /* Info blue-green */ + --info-foreground: oklch(0.985 0 0); + + /* UI Elements */ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --ring: oklch(0.5889 0.145 154.56); /* Focus ring matches primary */ + + /* Chart Colors */ --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); + + /* Border Radius */ --radius: 0.625rem; + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + + /* Sidebar */ --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); @@ -37,6 +58,37 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-serif: Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace; + + /* Spacing Scale */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + --spacing-3xl: 4rem; + + /* Z-index Scale */ + --z-base: 0; + --z-dropdown: 1000; + --z-sticky: 1020; + --z-fixed: 1030; + --z-modal-backdrop: 1040; + --z-modal: 1050; + --z-popover: 1060; + --z-tooltip: 1070; } .dark { @@ -119,6 +171,138 @@ } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground antialiased; + font-family: var(--font-sans); + font-feature-settings: 'rlig' 1, 'calt' 1; + } + + /* Typography Hierarchy */ + h1, h2, h3, h4, h5, h6 { + @apply font-semibold tracking-tight; + line-height: 1.25; + } + + h1 { + @apply text-4xl md:text-5xl; + } + + h2 { + @apply text-3xl md:text-4xl; + } + + h3 { + @apply text-2xl md:text-3xl; + } + + h4 { + @apply text-xl md:text-2xl; + } + + h5 { + @apply text-lg md:text-xl; + } + + h6 { + @apply text-base md:text-lg; + } + + p { + @apply leading-7; + } + + /* Smooth Scrolling */ + html { + scroll-behavior: smooth; + } + + /* Focus Visible */ + *:focus-visible { + @apply outline-2 outline-offset-2 outline-ring; + } + + /* Selection */ + ::selection { + background-color: oklch(0.5889 0.145 154.56 / 0.3); + color: oklch(0.145 0 0); + } + + /* Scrollbar Styling */ + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + @apply bg-muted; + } + + ::-webkit-scrollbar-thumb { + @apply bg-muted-foreground/30 rounded-lg; + } + + ::-webkit-scrollbar-thumb:hover { + @apply bg-muted-foreground/50; + } +} + +/* Utility Classes */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + .animate-in { + animation: enter 0.2s ease-out; + } + + .animate-out { + animation: exit 0.15s ease-in; + } + + @keyframes enter { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } + } + + @keyframes exit { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.95); + } + } + + /* Backdrop Blur Utility */ + .backdrop-blur-sm { + backdrop-filter: blur(4px); + } + + .backdrop-blur { + backdrop-filter: blur(8px); + } + + .backdrop-blur-md { + backdrop-filter: blur(12px); + } + + .backdrop-blur-lg { + backdrop-filter: blur(16px); + } + + /* Grid Pattern Background */ + .bg-grid-pattern { + background-image: + linear-gradient(to right, rgb(0 0 0 / 0.05) 1px, transparent 1px), + linear-gradient(to bottom, rgb(0 0 0 / 0.05) 1px, transparent 1px); + background-size: 40px 40px; } } diff --git a/stacks/backend_stack.py b/stacks/backend_stack.py index 7efef77..61289bb 100644 --- a/stacks/backend_stack.py +++ b/stacks/backend_stack.py @@ -249,7 +249,8 @@ def __init__( "s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload", - "s3:ListBucket" + "s3:ListBucket", + "s3:DeleteObject" ], resources=[ "arn:aws:s3:::uploadsstack-uploadsbucket5e5e9b64-luhskbfle3up",