Compare commits

...

3 Commits

39 changed files with 2148 additions and 675 deletions

View File

@ -0,0 +1,30 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import CompanyProfileLogo
class CompanyProfileLogosRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, logo_id: int) -> Optional[CompanyProfileLogo]:
query = (
select(CompanyProfileLogo)
.filter_by(id=logo_id)
)
result = await self.db.execute(query)
return result.scalars().first()
async def create(self, company_profile_logo: CompanyProfileLogo) -> CompanyProfileLogo:
self.db.add(company_profile_logo)
await self.db.commit()
await self.db.refresh(company_profile_logo)
return company_profile_logo
async def delete(self, company_profile_logo: CompanyProfileLogo) -> CompanyProfileLogo:
await self.db.delete(company_profile_logo)
await self.db.commit()
return company_profile_logo

View File

@ -0,0 +1,30 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import CompanyProfilePhoto
class CompanyProfilePhotosRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, photo_id: int) -> Optional[CompanyProfilePhoto]:
query = (
select(CompanyProfilePhoto)
.filter_by(id=photo_id)
)
result = await self.db.execute(query)
return result.scalars().first()
async def create(self, company_profile_photos: CompanyProfilePhoto) -> CompanyProfilePhoto:
self.db.add(company_profile_photos)
await self.db.commit()
await self.db.refresh(company_profile_photos)
return company_profile_photos
async def delete(self, company_profile_photos: CompanyProfilePhoto) -> CompanyProfilePhoto:
await self.db.delete(company_profile_photos)
await self.db.commit()
return company_profile_photos

View File

@ -0,0 +1,39 @@
from typing import Sequence, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import CompanyProfileSocial
class CompanyProfileSocialsRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_company_profile_id(self, company_profile_id: int) -> Sequence[CompanyProfileSocial]:
query = (
select(CompanyProfileSocial)
.filter_by(company_id=company_profile_id)
)
result = await self.db.execute(query)
return result.scalars().all()
async def create_list(self, company_profile_socials: List[CompanyProfileSocial]) -> Sequence[CompanyProfileSocial]:
self.db.add_all(company_profile_socials)
await self.db.commit()
for company_profile_social in company_profile_socials:
await self.db.refresh(company_profile_social)
return company_profile_socials
async def delete_list(
self,
company_profile_socials: List[CompanyProfileSocial] | Sequence[CompanyProfileSocial]
) -> Sequence[CompanyProfileSocial]:
for company_profile_social in company_profile_socials:
await self.db.delete(company_profile_social)
await self.db.commit()
return company_profile_socials

View File

@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.domain.models import CompanyProfile, CompanyProfileSocial from app.domain.models import CompanyProfile, CompanyProfileSocial
class CompanyProfilesRepository: class CompanyProfilesRepository:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
@ -16,24 +17,43 @@ class CompanyProfilesRepository:
joinedload(CompanyProfile.logo), joinedload(CompanyProfile.logo),
joinedload(CompanyProfile.official_photo), joinedload(CompanyProfile.official_photo),
joinedload(CompanyProfile.industry), joinedload(CompanyProfile.industry),
joinedload(CompanyProfile.socials) joinedload(CompanyProfile.socials),
joinedload(CompanyProfile.verification_requests),
) )
) )
result = await self.db.execute(query) result = await self.db.execute(query)
return result.scalars().first() return result.scalars().first()
async def get_company_by_creator_id(self, user_id: int) -> Optional[CompanyProfile]:
query = (
select(CompanyProfile)
.filter_by(creator_user_id=user_id)
.options(
joinedload(CompanyProfile.logo),
joinedload(CompanyProfile.official_photo),
joinedload(CompanyProfile.industry),
joinedload(CompanyProfile.socials),
joinedload(CompanyProfile.verification_requests),
)
)
result = await self.db.execute(query)
return result.scalars().first()
async def get_by_company_official_photo_id(self, logo_id: int) -> Optional[CompanyProfile]:
query = (
select(CompanyProfile)
.filter_by(official_photo_id=logo_id)
)
result = await self.db.execute(query)
return result.scalars().first()
async def create(self, company: CompanyProfile) -> CompanyProfile: async def create(self, company: CompanyProfile) -> CompanyProfile:
self.db.add(company) self.db.add(company)
await self.db.commit() await self.db.commit()
await self.db.refresh(company) await self.db.refresh(company)
return company return company
async def add_socials(self, socials: List[CompanyProfileSocial]): async def update(self, company: CompanyProfile) -> CompanyProfile:
self.db.add_all(socials) await self.db.merge(company)
await self.db.commit()
async def delete_socials_by_company_id(self, company_id: int):
from sqlalchemy import delete
query = delete(CompanyProfileSocial).filter_by(company_id=company_id)
await self.db.execute(query)
await self.db.commit() await self.db.commit()
return company

View File

@ -0,0 +1,38 @@
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import VerificationRequest
class VerificationRequestSRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_id(self, verification_request_id: int) -> VerificationRequest:
query = (
select(VerificationRequest)
.filter_by(id=verification_request_id)
)
result = await self.db.execute(query)
return result.scalars().first()
async def get_by_company_profile_id(self, company_profile_id: int) -> Sequence[VerificationRequest]:
query = (
select(VerificationRequest)
.filter_by(company_id=company_profile_id)
)
result = await self.db.execute(query)
return result.scalars().all()
async def create(self, verification_request: VerificationRequest) -> VerificationRequest:
self.db.add(verification_request)
await self.db.commit()
await self.db.refresh(verification_request)
return verification_request
async def update(self, verification_request: VerificationRequest) -> VerificationRequest:
await self.db.merge(verification_request)
await self.db.commit()
return verification_request

View File

@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from app.database.session import get_db
from app.domain.entities.company_profile_photos import CompanyProfilePhotoRead
from app.domain.models import User
from app.infrastructure.company_profile_logos_router import CompanyProfileLogosService
from app.infrastructure.dependencies import require_employer
company_profile_logos_router = APIRouter()
@company_profile_logos_router.get(
'/file/{file_id}/',
response_class=FileResponse,
)
async def get_file(
file_id: int,
db: AsyncSession = Depends(get_db),
):
company_profile_logos_service = CompanyProfileLogosService(db)
return await company_profile_logos_service.get_file_by_id(file_id)
@company_profile_logos_router.post(
'/files/{company_profile_id}/upload/',
response_model=CompanyProfilePhotoRead,
summary='Upload a file',
description='Upload a file',
)
async def upload_file(
company_profile_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
company_profile_logos_service = CompanyProfileLogosService(db)
return await company_profile_logos_service.upload_file(company_profile_id, file, user)
@company_profile_logos_router.delete(
'/files/{company_profile_id}/',
response_model=CompanyProfilePhotoRead,
summary='Delete a file',
description='Delete a file',
)
async def delete_file(
company_profile_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
company_profile_logos_service = CompanyProfileLogosService(db)
return await company_profile_logos_service.delete_file(company_profile_id, user)

View File

@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from app.database.session import get_db
from app.domain.entities.company_profile_photos import CompanyProfilePhotoRead
from app.domain.models import User
from app.infrastructure.company_profile_photos_router import CompanyProfilePhotosService
from app.infrastructure.dependencies import require_employer
company_profile_photos_router = APIRouter()
@company_profile_photos_router.get(
'/file/{file_id}/',
response_class=FileResponse,
)
async def get_file(
file_id: int,
db: AsyncSession = Depends(get_db),
):
company_profile_photos_service = CompanyProfilePhotosService(db)
return await company_profile_photos_service.get_file_by_id(file_id)
@company_profile_photos_router.post(
'/files/{company_profile_id}/upload/',
response_model=CompanyProfilePhotoRead,
summary='Upload a file',
description='Upload a file',
)
async def upload_file(
company_profile_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
company_profile_photos_service = CompanyProfilePhotosService(db)
return await company_profile_photos_service.upload_file(company_profile_id, file, user)
@company_profile_photos_router.delete(
'/files/{company_profile_id}/',
response_model=CompanyProfilePhotoRead,
summary='Delete a file',
description='Delete a file',
)
async def delete_file(
company_profile_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
company_profile_photos_service = CompanyProfilePhotosService(db)
return await company_profile_photos_service.delete_file(company_profile_id, user)

View File

@ -0,0 +1,43 @@
from typing import Optional, List
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db
from app.domain.entities.company_profile_socials import CompanyProfileSocialRead, CompanyProfileSocialCreate
from app.domain.models import User
from app.infrastructure.company_profile_socials_service import CompanyProfileSocialsService
from app.infrastructure.dependencies import require_employer
company_profile_socials_router = APIRouter()
@company_profile_socials_router.get(
'/{company_profile_id}/',
response_model=Optional[List[CompanyProfileSocialRead]],
summary='Get company profile socials',
description='Get company profile socials',
)
async def get_company_profile_socials(
company_profile_id: int,
db: AsyncSession = Depends(get_db),
):
company_profile_socials_service = CompanyProfileSocialsService(db)
return await company_profile_socials_service.get_by_company_profile_id(company_profile_id)
@company_profile_socials_router.post(
'/{company_profile_id}/',
response_model=Optional[List[CompanyProfileSocialRead]],
summary='Replace company profile social',
description='Replace company profile social',
)
async def replace_company_profile_social(
company_profile_id: int,
company_profile_socials: List[CompanyProfileSocialCreate],
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
company_profile_social_service = CompanyProfileSocialsService(db)
return await company_profile_social_service.replace_company_profile_socials(company_profile_socials,
company_profile_id, user)

View File

@ -1,44 +1,57 @@
from typing import Optional
from fastapi import APIRouter, Depends, status, HTTPException from fastapi import APIRouter, Depends, status, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db from app.database.session import get_db
from app.domain.entities.company_profiles import CompanyProfileCreate, CompanyProfileRead from app.domain.entities.company_profiles import CompanyProfileCreate, CompanyProfileRead, CompanyProfileUpdate
from app.domain.models import User from app.domain.models import User
from app.infrastructure.company_profiles_service import CompanyProfilesService from app.infrastructure.company_profiles_service import CompanyProfilesService
from app.infrastructure.dependencies import require_auth_user from app.infrastructure.dependencies import require_employer
# Это имя должно совпадать с тем, что импортируется в main.py
company_profiles_router = APIRouter() company_profiles_router = APIRouter()
@company_profiles_router.get( @company_profiles_router.get(
'/me', '/me/',
response_model=CompanyProfileRead, response_model=Optional[CompanyProfileRead],
summary='Получить профиль текущей компании', summary='Get a company profile',
description='Get a company profile',
) )
async def get_my_company( async def get_my_company(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth_user) user: User = Depends(require_employer)
): ):
"""Возвращает профиль компании для авторизованного пользователя""" company_profile_service = CompanyProfilesService(db)
service = CompanyProfilesService(db) return await company_profile_service.get_company_by_creator_id(user.id)
profile = await service.get_company_by_creator_id(user.id)
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Профиль компании не найден"
)
return profile
@company_profiles_router.post( @company_profiles_router.post(
'/', '/',
response_model=CompanyProfileRead, response_model=Optional[CompanyProfileRead],
status_code=status.HTTP_201_CREATED, summary='Create a new company profile',
summary='Создать профиль компании', description='Create a new company profile',
) )
async def create_company( async def create_company(
company_data: CompanyProfileCreate, company_data: CompanyProfileCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth_user) user: User = Depends(require_employer)
): ):
"""Создает новый профиль компании""" company_profile_service = CompanyProfilesService(db)
service = CompanyProfilesService(db) return await company_profile_service.create_company(company_data, user)
return await service.create_company(company_data, user)
@company_profiles_router.put(
'/{company_id}/',
response_model=Optional[CompanyProfileRead],
summary='Update a company profile',
description='Update a company profile',
)
async def update_company(
company_id: int,
company_data: CompanyProfileUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
company_profile_service = CompanyProfilesService(db)
return await company_profile_service.update_company(company_id, company_data, user)

View File

@ -3,16 +3,19 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db from app.database.session import get_db
from app.domain.entities.industries import IndustryRead from app.domain.entities.industries import IndustryRead
from app.application.industries_repository import IndustriesRepository from app.infrastructure.industries_services import IndustriesService
industries_router = APIRouter() industries_router = APIRouter()
@industries_router.get( @industries_router.get(
'/', '/',
response_model=List[IndustryRead], response_model=List[IndustryRead],
summary='Получить список всех сфер деятельности', summary='Get all industries',
description='Get all industries',
) )
async def get_all_industries(db: AsyncSession = Depends(get_db)): async def get_all_industries(
repo = IndustriesRepository(db) db: AsyncSession = Depends(get_db),
industries = await repo.get_all() ):
return [IndustryRead.model_validate(i) for i in industries] industries_service = IndustriesService(db)
return await industries_service.get_all()

View File

@ -0,0 +1,42 @@
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_db
from app.domain.entities.verification_requests import VerificationRequestRead
from app.domain.models import User
from app.infrastructure.company_verification_service import CompanyVerificationRequestsService
from app.infrastructure.dependencies import require_employer
verification_requests_router = APIRouter()
@verification_requests_router.get(
'/{company_id}/',
response_model=List[VerificationRequestRead],
summary=f'Get all verification requests',
description='Get all verification requests',
)
async def get_all_verification_requests(
company_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
verification_requests_service = CompanyVerificationRequestsService(db)
return await verification_requests_service.get_by_company_id(company_id, user)
@verification_requests_router.post(
'/{company_id}/',
response_model=VerificationRequestRead,
summary='Create a new verification request',
description='Create a new verification request',
)
async def create_verification_request(
company_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_employer),
):
verification_requests_service = CompanyVerificationRequestsService(db)
return await verification_requests_service.create_verification_request(company_id, user)

View File

@ -0,0 +1,224 @@
"""0008 изменил тблицу с заявками на верификацию
Revision ID: 08d60de6f70b
Revises: 95c2576d5d3d
Create Date: 2026-04-08 22:31:55.772985
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '08d60de6f70b'
down_revision: Union[str, Sequence[str], None] = '95c2576d5d3d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('history')
op.drop_constraint(op.f('applicant_contact_recommendations_recommender_id_fkey'), 'applicant_contact_recommendations', type_='foreignkey')
op.drop_constraint(op.f('applicant_contact_recommendations_recipient_id_fkey'), 'applicant_contact_recommendations', type_='foreignkey')
op.create_foreign_key(None, 'applicant_contact_recommendations', 'applicant_profiles', ['recommender_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'applicant_contact_recommendations', 'applicant_profiles', ['recipient_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_contacts_status_id_fkey'), 'applicant_contacts', type_='foreignkey')
op.drop_constraint(op.f('applicant_contacts_receiver_id_fkey'), 'applicant_contacts', type_='foreignkey')
op.drop_constraint(op.f('applicant_contacts_sender_id_fkey'), 'applicant_contacts', type_='foreignkey')
op.create_foreign_key(None, 'applicant_contacts', 'applicant_profiles', ['receiver_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'applicant_contacts', 'applicant_profiles', ['sender_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'applicant_contacts', 'applicant_contact_statuses', ['status_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_educations_applicant_id_fkey'), 'applicant_educations', type_='foreignkey')
op.drop_constraint(op.f('applicant_educations_university_id_fkey'), 'applicant_educations', type_='foreignkey')
op.create_foreign_key(None, 'applicant_educations', 'universities', ['university_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'applicant_educations', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_experiences_applicant_id_fkey'), 'applicant_experiences', type_='foreignkey')
op.create_foreign_key(None, 'applicant_experiences', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_favorite_companies_company_id_fkey'), 'applicant_favorite_companies', type_='foreignkey')
op.drop_constraint(op.f('applicant_favorite_companies_applicant_id_fkey'), 'applicant_favorite_companies', type_='foreignkey')
op.create_foreign_key(None, 'applicant_favorite_companies', 'company_profiles', ['company_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'applicant_favorite_companies', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_profiles_user_id_fkey'), 'applicant_profiles', type_='foreignkey')
op.create_foreign_key(None, 'applicant_profiles', 'users', ['user_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_resume_files_applicant_id_fkey'), 'applicant_resume_files', type_='foreignkey')
op.create_foreign_key(None, 'applicant_resume_files', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_resume_links_applicant_id_fkey'), 'applicant_resume_links', type_='foreignkey')
op.create_foreign_key(None, 'applicant_resume_links', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_resume_projects_applicant_id_fkey'), 'applicant_resume_projects', type_='foreignkey')
op.create_foreign_key(None, 'applicant_resume_projects', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('applicant_skills_applicant_id_fkey'), 'applicant_skills', type_='foreignkey')
op.drop_constraint(op.f('applicant_skills_level_id_fkey'), 'applicant_skills', type_='foreignkey')
op.drop_constraint(op.f('applicant_skills_tag_id_fkey'), 'applicant_skills', type_='foreignkey')
op.create_foreign_key(None, 'applicant_skills', 'applicant_skill_tags', ['tag_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'applicant_skills', 'experience_levels', ['level_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'applicant_skills', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('career_events_location_id_fkey'), 'career_events', type_='foreignkey')
op.drop_constraint(op.f('career_events_company_id_fkey'), 'career_events', type_='foreignkey')
op.drop_constraint(op.f('career_events_moderation_status_id_fkey'), 'career_events', type_='foreignkey')
op.drop_constraint(op.f('career_events_event_type_id_fkey'), 'career_events', type_='foreignkey')
op.create_foreign_key(None, 'career_events', 'location_coordinates', ['location_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'career_events', 'event_types', ['event_type_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'career_events', 'moderation_statuses', ['moderation_status_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'career_events', 'company_profiles', ['company_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('company_profile_socials_company_id_fkey'), 'company_profile_socials', type_='foreignkey')
op.create_foreign_key(None, 'company_profile_socials', 'company_profiles', ['company_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('company_profiles_official_photo_id_fkey'), 'company_profiles', type_='foreignkey')
op.drop_constraint(op.f('company_profiles_logo_id_fkey'), 'company_profiles', type_='foreignkey')
op.drop_constraint(op.f('company_profiles_creator_user_id_fkey'), 'company_profiles', type_='foreignkey')
op.drop_constraint(op.f('company_profiles_industry_id_fkey'), 'company_profiles', type_='foreignkey')
op.create_foreign_key(None, 'company_profiles', 'company_profile_photos', ['official_photo_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'company_profiles', 'industries', ['industry_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'company_profiles', 'company_profile_logos', ['logo_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'company_profiles', 'users', ['creator_user_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('internships_company_id_fkey'), 'internships', type_='foreignkey')
op.drop_constraint(op.f('internships_location_id_fkey'), 'internships', type_='foreignkey')
op.drop_constraint(op.f('internships_experience_level_id_fkey'), 'internships', type_='foreignkey')
op.drop_constraint(op.f('internships_moderation_status_id_fkey'), 'internships', type_='foreignkey')
op.drop_constraint(op.f('internships_work_format_id_fkey'), 'internships', type_='foreignkey')
op.create_foreign_key(None, 'internships', 'moderation_statuses', ['moderation_status_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'internships', 'location_coordinates', ['location_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'internships', 'company_profiles', ['company_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'internships', 'work_formats', ['work_format_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'internships', 'experience_levels', ['experience_level_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('mentorship_programs_location_id_fkey'), 'mentorship_programs', type_='foreignkey')
op.drop_constraint(op.f('mentorship_programs_moderation_status_id_fkey'), 'mentorship_programs', type_='foreignkey')
op.drop_constraint(op.f('mentorship_programs_company_id_fkey'), 'mentorship_programs', type_='foreignkey')
op.create_foreign_key(None, 'mentorship_programs', 'location_coordinates', ['location_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'mentorship_programs', 'company_profiles', ['company_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'mentorship_programs', 'moderation_statuses', ['moderation_status_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('users_role_id_fkey'), 'users', type_='foreignkey')
op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('vacancies_employment_type_id_fkey'), 'vacancies', type_='foreignkey')
op.drop_constraint(op.f('vacancies_work_format_id_fkey'), 'vacancies', type_='foreignkey')
op.drop_constraint(op.f('vacancies_moderation_status_id_fkey'), 'vacancies', type_='foreignkey')
op.drop_constraint(op.f('vacancies_location_id_fkey'), 'vacancies', type_='foreignkey')
op.drop_constraint(op.f('vacancies_experience_level_id_fkey'), 'vacancies', type_='foreignkey')
op.drop_constraint(op.f('vacancies_company_id_fkey'), 'vacancies', type_='foreignkey')
op.create_foreign_key(None, 'vacancies', 'moderation_statuses', ['moderation_status_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'vacancies', 'location_coordinates', ['location_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'vacancies', 'work_formats', ['work_format_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'vacancies', 'employment_types', ['employment_type_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'vacancies', 'experience_levels', ['experience_level_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'vacancies', 'company_profiles', ['company_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint(op.f('vacancy_applications_applicant_id_fkey'), 'vacancy_applications', type_='foreignkey')
op.drop_constraint(op.f('vacancy_applications_vacancy_id_fkey'), 'vacancy_applications', type_='foreignkey')
op.create_foreign_key(None, 'vacancy_applications', 'applicant_profiles', ['applicant_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'vacancy_applications', 'vacancies', ['vacancy_id'], ['id'], source_schema='public', referent_schema='public')
op.alter_column('verification_requests', 'is_accepted',
existing_type=sa.BOOLEAN(),
nullable=True)
op.drop_constraint(op.f('verification_requests_company_id_fkey'), 'verification_requests', type_='foreignkey')
op.create_foreign_key(None, 'verification_requests', 'company_profiles', ['company_id'], ['id'], source_schema='public', referent_schema='public')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'verification_requests', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('verification_requests_company_id_fkey'), 'verification_requests', 'company_profiles', ['company_id'], ['id'])
op.alter_column('verification_requests', 'is_accepted',
existing_type=sa.BOOLEAN(),
nullable=False)
op.drop_constraint(None, 'vacancy_applications', schema='public', type_='foreignkey')
op.drop_constraint(None, 'vacancy_applications', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('vacancy_applications_vacancy_id_fkey'), 'vacancy_applications', 'vacancies', ['vacancy_id'], ['id'])
op.create_foreign_key(op.f('vacancy_applications_applicant_id_fkey'), 'vacancy_applications', 'applicant_profiles', ['applicant_id'], ['id'])
op.drop_constraint(None, 'vacancies', schema='public', type_='foreignkey')
op.drop_constraint(None, 'vacancies', schema='public', type_='foreignkey')
op.drop_constraint(None, 'vacancies', schema='public', type_='foreignkey')
op.drop_constraint(None, 'vacancies', schema='public', type_='foreignkey')
op.drop_constraint(None, 'vacancies', schema='public', type_='foreignkey')
op.drop_constraint(None, 'vacancies', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('vacancies_company_id_fkey'), 'vacancies', 'company_profiles', ['company_id'], ['id'])
op.create_foreign_key(op.f('vacancies_experience_level_id_fkey'), 'vacancies', 'experience_levels', ['experience_level_id'], ['id'])
op.create_foreign_key(op.f('vacancies_location_id_fkey'), 'vacancies', 'location_coordinates', ['location_id'], ['id'])
op.create_foreign_key(op.f('vacancies_moderation_status_id_fkey'), 'vacancies', 'moderation_statuses', ['moderation_status_id'], ['id'])
op.create_foreign_key(op.f('vacancies_work_format_id_fkey'), 'vacancies', 'work_formats', ['work_format_id'], ['id'])
op.create_foreign_key(op.f('vacancies_employment_type_id_fkey'), 'vacancies', 'employment_types', ['employment_type_id'], ['id'])
op.drop_constraint(None, 'users', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('users_role_id_fkey'), 'users', 'roles', ['role_id'], ['id'])
op.drop_constraint(None, 'mentorship_programs', schema='public', type_='foreignkey')
op.drop_constraint(None, 'mentorship_programs', schema='public', type_='foreignkey')
op.drop_constraint(None, 'mentorship_programs', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('mentorship_programs_company_id_fkey'), 'mentorship_programs', 'company_profiles', ['company_id'], ['id'])
op.create_foreign_key(op.f('mentorship_programs_moderation_status_id_fkey'), 'mentorship_programs', 'moderation_statuses', ['moderation_status_id'], ['id'])
op.create_foreign_key(op.f('mentorship_programs_location_id_fkey'), 'mentorship_programs', 'location_coordinates', ['location_id'], ['id'])
op.drop_constraint(None, 'internships', schema='public', type_='foreignkey')
op.drop_constraint(None, 'internships', schema='public', type_='foreignkey')
op.drop_constraint(None, 'internships', schema='public', type_='foreignkey')
op.drop_constraint(None, 'internships', schema='public', type_='foreignkey')
op.drop_constraint(None, 'internships', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('internships_work_format_id_fkey'), 'internships', 'work_formats', ['work_format_id'], ['id'])
op.create_foreign_key(op.f('internships_moderation_status_id_fkey'), 'internships', 'moderation_statuses', ['moderation_status_id'], ['id'])
op.create_foreign_key(op.f('internships_experience_level_id_fkey'), 'internships', 'experience_levels', ['experience_level_id'], ['id'])
op.create_foreign_key(op.f('internships_location_id_fkey'), 'internships', 'location_coordinates', ['location_id'], ['id'])
op.create_foreign_key(op.f('internships_company_id_fkey'), 'internships', 'company_profiles', ['company_id'], ['id'])
op.drop_constraint(None, 'company_profiles', schema='public', type_='foreignkey')
op.drop_constraint(None, 'company_profiles', schema='public', type_='foreignkey')
op.drop_constraint(None, 'company_profiles', schema='public', type_='foreignkey')
op.drop_constraint(None, 'company_profiles', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('company_profiles_industry_id_fkey'), 'company_profiles', 'industries', ['industry_id'], ['id'])
op.create_foreign_key(op.f('company_profiles_creator_user_id_fkey'), 'company_profiles', 'users', ['creator_user_id'], ['id'])
op.create_foreign_key(op.f('company_profiles_logo_id_fkey'), 'company_profiles', 'company_profile_logos', ['logo_id'], ['id'])
op.create_foreign_key(op.f('company_profiles_official_photo_id_fkey'), 'company_profiles', 'company_profile_photos', ['official_photo_id'], ['id'])
op.drop_constraint(None, 'company_profile_socials', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('company_profile_socials_company_id_fkey'), 'company_profile_socials', 'company_profiles', ['company_id'], ['id'])
op.drop_constraint(None, 'career_events', schema='public', type_='foreignkey')
op.drop_constraint(None, 'career_events', schema='public', type_='foreignkey')
op.drop_constraint(None, 'career_events', schema='public', type_='foreignkey')
op.drop_constraint(None, 'career_events', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('career_events_event_type_id_fkey'), 'career_events', 'event_types', ['event_type_id'], ['id'])
op.create_foreign_key(op.f('career_events_moderation_status_id_fkey'), 'career_events', 'moderation_statuses', ['moderation_status_id'], ['id'])
op.create_foreign_key(op.f('career_events_company_id_fkey'), 'career_events', 'company_profiles', ['company_id'], ['id'])
op.create_foreign_key(op.f('career_events_location_id_fkey'), 'career_events', 'location_coordinates', ['location_id'], ['id'])
op.drop_constraint(None, 'applicant_skills', schema='public', type_='foreignkey')
op.drop_constraint(None, 'applicant_skills', schema='public', type_='foreignkey')
op.drop_constraint(None, 'applicant_skills', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_skills_tag_id_fkey'), 'applicant_skills', 'applicant_skill_tags', ['tag_id'], ['id'])
op.create_foreign_key(op.f('applicant_skills_level_id_fkey'), 'applicant_skills', 'experience_levels', ['level_id'], ['id'])
op.create_foreign_key(op.f('applicant_skills_applicant_id_fkey'), 'applicant_skills', 'applicant_profiles', ['applicant_id'], ['id'])
op.drop_constraint(None, 'applicant_resume_projects', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_resume_projects_applicant_id_fkey'), 'applicant_resume_projects', 'applicant_profiles', ['applicant_id'], ['id'])
op.drop_constraint(None, 'applicant_resume_links', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_resume_links_applicant_id_fkey'), 'applicant_resume_links', 'applicant_profiles', ['applicant_id'], ['id'])
op.drop_constraint(None, 'applicant_resume_files', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_resume_files_applicant_id_fkey'), 'applicant_resume_files', 'applicant_profiles', ['applicant_id'], ['id'])
op.drop_constraint(None, 'applicant_profiles', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_profiles_user_id_fkey'), 'applicant_profiles', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'applicant_favorite_companies', schema='public', type_='foreignkey')
op.drop_constraint(None, 'applicant_favorite_companies', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_favorite_companies_applicant_id_fkey'), 'applicant_favorite_companies', 'applicant_profiles', ['applicant_id'], ['id'])
op.create_foreign_key(op.f('applicant_favorite_companies_company_id_fkey'), 'applicant_favorite_companies', 'company_profiles', ['company_id'], ['id'])
op.drop_constraint(None, 'applicant_experiences', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_experiences_applicant_id_fkey'), 'applicant_experiences', 'applicant_profiles', ['applicant_id'], ['id'])
op.drop_constraint(None, 'applicant_educations', schema='public', type_='foreignkey')
op.drop_constraint(None, 'applicant_educations', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_educations_university_id_fkey'), 'applicant_educations', 'universities', ['university_id'], ['id'])
op.create_foreign_key(op.f('applicant_educations_applicant_id_fkey'), 'applicant_educations', 'applicant_profiles', ['applicant_id'], ['id'])
op.drop_constraint(None, 'applicant_contacts', schema='public', type_='foreignkey')
op.drop_constraint(None, 'applicant_contacts', schema='public', type_='foreignkey')
op.drop_constraint(None, 'applicant_contacts', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_contacts_sender_id_fkey'), 'applicant_contacts', 'applicant_profiles', ['sender_id'], ['id'])
op.create_foreign_key(op.f('applicant_contacts_receiver_id_fkey'), 'applicant_contacts', 'applicant_profiles', ['receiver_id'], ['id'])
op.create_foreign_key(op.f('applicant_contacts_status_id_fkey'), 'applicant_contacts', 'applicant_contact_statuses', ['status_id'], ['id'])
op.drop_constraint(None, 'applicant_contact_recommendations', schema='public', type_='foreignkey')
op.drop_constraint(None, 'applicant_contact_recommendations', schema='public', type_='foreignkey')
op.create_foreign_key(op.f('applicant_contact_recommendations_recipient_id_fkey'), 'applicant_contact_recommendations', 'applicant_profiles', ['recipient_id'], ['id'])
op.create_foreign_key(op.f('applicant_contact_recommendations_recommender_id_fkey'), 'applicant_contact_recommendations', 'applicant_profiles', ['recommender_id'], ['id'])
op.create_table('history',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('tstamp', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('schemaname', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('tabname', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('operation', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('who', sa.TEXT(), server_default=sa.text('CURRENT_USER'), autoincrement=False, nullable=True),
sa.Column('new_val', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('old_val', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('item_id', sa.BIGINT(), autoincrement=False, nullable=True)
)
# ### end Alembic commands ###

View File

@ -5,6 +5,8 @@ from app.domain.entities.industries import IndustryRead
from app.domain.entities.company_profile_socials import CompanyProfileSocialCreate, CompanyProfileSocialRead from app.domain.entities.company_profile_socials import CompanyProfileSocialCreate, CompanyProfileSocialRead
from app.domain.entities.company_profile_logos import CompanyProfileLogoRead from app.domain.entities.company_profile_logos import CompanyProfileLogoRead
from app.domain.entities.company_profile_photos import CompanyProfilePhotoRead from app.domain.entities.company_profile_photos import CompanyProfilePhotoRead
from app.domain.entities.verification_requests import VerificationRequestRead
class CompanyProfileCreate(BaseModel): class CompanyProfileCreate(BaseModel):
title: str = Field(max_length=250) title: str = Field(max_length=250)
@ -19,6 +21,7 @@ class CompanyProfileCreate(BaseModel):
industry_id: int industry_id: int
socials: Optional[List[CompanyProfileSocialCreate]] = [] socials: Optional[List[CompanyProfileSocialCreate]] = []
class CompanyProfileUpdate(BaseModel): class CompanyProfileUpdate(BaseModel):
title: Optional[str] = Field(default=None, max_length=250) title: Optional[str] = Field(default=None, max_length=250)
description: Optional[str] = None description: Optional[str] = None
@ -31,6 +34,7 @@ class CompanyProfileUpdate(BaseModel):
official_photo_id: Optional[int] = None official_photo_id: Optional[int] = None
industry_id: Optional[int] = None industry_id: Optional[int] = None
class CompanyProfileRead(BaseModel): class CompanyProfileRead(BaseModel):
id: int id: int
title: str title: str
@ -49,6 +53,7 @@ class CompanyProfileRead(BaseModel):
official_photo: Optional[CompanyProfilePhotoRead] = None official_photo: Optional[CompanyProfilePhotoRead] = None
industry: IndustryRead industry: IndustryRead
socials: List[CompanyProfileSocialRead] = [] socials: List[CompanyProfileSocialRead] = []
verification_requests: List[VerificationRequestRead] = []
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel
class CompanyVerificationRequestCreate(BaseModel):
company_id: int
class VerificationRequestRead(BaseModel):
id: int
is_accepted: Optional[bool] = None
company_id: int
class Config:
from_attributes = True

View File

@ -7,7 +7,7 @@ from app.domain.models.base import RootTable
class VerificationRequest(RootTable): class VerificationRequest(RootTable):
__tablename__ = 'verification_requests' __tablename__ = 'verification_requests'
is_accepted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_accepted: Mapped[bool] = mapped_column(Boolean, nullable=True, default=None)
company_id: Mapped[int] = mapped_column(ForeignKey('company_profiles.id'), nullable=False) company_id: Mapped[int] = mapped_column(ForeignKey('company_profiles.id'), nullable=False)

View File

@ -0,0 +1,103 @@
import os
import aiofiles
from fastapi import UploadFile, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from app.application.company_profile_logos_repository import CompanyProfileLogosRepository
from app.application.company_profiles_repository import CompanyProfilesRepository
from app.core.constants import UserRoles
from app.domain.entities.company_profile_logos import CompanyProfileLogoRead
from app.domain.models import User, CompanyProfileLogo
from app.infrastructure.files_service import FilesService
class CompanyProfileLogosService:
def __init__(self, db: AsyncSession):
self.company_profile_logos_repository = CompanyProfileLogosRepository(db)
self.company_profiles_repository = CompanyProfilesRepository(db)
self.files_service = FilesService()
async def get_file_by_id(self, file_id: int) -> FileResponse:
company_profile_photo = await self.company_profile_logos_repository.get_by_id(file_id)
if not company_profile_photo:
raise HTTPException(404, "Файл с таким ID не найден")
return FileResponse(
company_profile_photo.path,
media_type=self.files_service.get_media_type(company_profile_photo.filename),
filename=os.path.basename(company_profile_photo.filename),
)
async def upload_file(self, company_profile_id: int, file: UploadFile,
current_user: User) -> CompanyProfileLogoRead:
company_profile = await self.company_profiles_repository.get_by_id(company_profile_id)
if company_profile is None:
raise HTTPException(404, "Компания не найдена")
if company_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
excising_photo = await self.company_profile_logos_repository.get_by_id(company_profile.logo_id)
if excising_photo:
await self.delete_file(excising_photo.id, current_user)
file_path = await self.files_service.save_file(file, f'uploads/company_profile_logos/{company_profile.id}')
company_profile_logo_model = CompanyProfileLogo(
filename=file.filename,
path=file_path,
)
company_profile_logo_model = await self.company_profile_logos_repository.create(company_profile_logo_model)
company_profile.logo_id = company_profile_logo_model.id
await self.company_profiles_repository.update(company_profile)
return CompanyProfileLogoRead.model_validate(company_profile_logo_model)
async def delete_file(self, file_id: int, current_user: User) -> CompanyProfileLogoRead:
company_profile = await self.company_profiles_repository.get_by_company_logo_id(file_id)
if company_profile is None and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Данное фото не привязано к компании',
)
if company_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
company_profile_photo = await self.company_profile_logos_repository.get_by_id(file_id)
if company_profile_photo is None:
raise HTTPException(404, "Файл не найден")
if not os.path.exists(company_profile_photo.path):
raise HTTPException(404, "Файл не найден на диске")
if os.path.exists(company_profile_photo.path):
os.remove(company_profile_photo.path)
company_profile.logo_id = None
await self.company_profiles_repository.update(company_profile)
company_profile_photo = await self.company_profile_logos_repository.delete(company_profile_photo)
return CompanyProfileLogoRead.model_validate(company_profile_photo)

View File

@ -0,0 +1,103 @@
import os
import aiofiles
from fastapi import UploadFile, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from app.application.company_profile_photos_repository import CompanyProfilePhotosRepository
from app.application.company_profiles_repository import CompanyProfilesRepository
from app.core.constants import UserRoles
from app.domain.entities.company_profile_photos import CompanyProfilePhotoRead
from app.domain.models import User, CompanyProfilePhoto
from app.infrastructure.files_service import FilesService
class CompanyProfilePhotosService:
def __init__(self, db: AsyncSession):
self.company_profile_photos_repository = CompanyProfilePhotosRepository(db)
self.company_profiles_repository = CompanyProfilesRepository(db)
self.files_service = FilesService()
async def get_file_by_id(self, file_id: int) -> FileResponse:
company_profile_photo = await self.company_profile_photos_repository.get_by_id(file_id)
if not company_profile_photo:
raise HTTPException(404, "Файл с таким ID не найден")
return FileResponse(
company_profile_photo.path,
media_type=self.files_service.get_media_type(company_profile_photo.filename),
filename=os.path.basename(company_profile_photo.filename),
)
async def upload_file(self, company_profile_id: int, file: UploadFile,
current_user: User) -> CompanyProfilePhotoRead:
company_profile = await self.company_profiles_repository.get_by_id(company_profile_id)
if company_profile is None:
raise HTTPException(404, "Компания не найдена")
if company_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
excising_photo = await self.company_profile_photos_repository.get_by_id(company_profile.official_photo_id)
if excising_photo:
await self.delete_file(excising_photo.id, current_user)
file_path = await self.files_service.save_file(file, f'uploads/company_profile_photos/{company_profile.id}')
company_profile_photo_model = CompanyProfilePhoto(
filename=file.filename,
path=file_path,
)
company_profile_photo_model = await self.company_profile_photos_repository.create(company_profile_photo_model)
company_profile.official_photo_id = company_profile_photo_model.id
await self.company_profiles_repository.update(company_profile)
return CompanyProfilePhotoRead.model_validate(company_profile_photo_model)
async def delete_file(self, file_id: int, current_user: User) -> CompanyProfilePhotoRead:
company_profile = await self.company_profiles_repository.get_by_company_official_photo_id(file_id)
if company_profile is None and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Данное фото не привязано к компании',
)
if company_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
company_profile_photo = await self.company_profile_photos_repository.get_by_id(file_id)
if company_profile_photo is None:
raise HTTPException(404, "Файл не найден")
if not os.path.exists(company_profile_photo.path):
raise HTTPException(404, "Файл не найден на диске")
if os.path.exists(company_profile_photo.path):
os.remove(company_profile_photo.path)
company_profile.official_photo_id = None
await self.company_profiles_repository.update(company_profile)
company_profile_photo = await self.company_profile_photos_repository.delete(company_profile_photo)
return CompanyProfilePhotoRead.model_validate(company_profile_photo)

View File

@ -0,0 +1,79 @@
from typing import List
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.company_profile_socials_repository import CompanyProfileSocialsRepository
from app.application.company_profiles_repository import CompanyProfilesRepository
from app.core.constants import UserRoles
from app.domain.entities.company_profile_socials import CompanyProfileSocialRead, CompanyProfileSocialCreate
from app.domain.models import User, CompanyProfileSocial
class CompanyProfileSocialsService:
def __init__(self, db: AsyncSession):
self.company_profile_socials_repository = CompanyProfileSocialsRepository(db)
self.company_profiles_repository = CompanyProfilesRepository(db)
async def get_by_company_profile_id(
self,
company_profile_id: int,
) -> List[CompanyProfileSocialRead]:
company_profile = await self.company_profiles_repository.get_by_id(company_profile_id)
if not company_profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Компания с таким ID не найдена'
)
company_profile_socials = await self.company_profile_socials_repository.get_by_company_profile_id(
company_profile_id
)
response = []
for company_profile_social in company_profile_socials:
response.append(CompanyProfileSocialRead.model_validate(company_profile_social))
return response
async def replace_company_profile_socials(
self,
company_profile_socials: List[CompanyProfileSocialCreate],
company_profile_id: int,
current_user: User,
) -> List[CompanyProfileSocialRead]:
company_profile = await self.company_profiles_repository.get_by_id(company_profile_id)
if not company_profile:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Соискатель с таким ID не найден')
if company_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
old_company_profile_socials = await self.company_profile_socials_repository.get_by_company_profile_id(
company_profile_id
)
await self.company_profile_socials_repository.delete_list(old_company_profile_socials)
company_profile_social_models = []
for company_profile_social in company_profile_socials:
company_profile_social_models.append(CompanyProfileSocial(
title=company_profile_social.title,
link=company_profile_social.link,
company_id=company_profile_id,
))
company_profile_social_models = await self.company_profile_socials_repository.create_list(
company_profile_social_models
)
response = []
for company_profile_social_model in company_profile_social_models:
response.append(CompanyProfileSocialRead.model_validate(company_profile_social_model))
return response

View File

@ -6,59 +6,42 @@ from sqlalchemy.orm import joinedload
from app.application.company_profiles_repository import CompanyProfilesRepository from app.application.company_profiles_repository import CompanyProfilesRepository
from app.application.industries_repository import IndustriesRepository from app.application.industries_repository import IndustriesRepository
from app.core.constants import UserRoles
from app.domain.models import CompanyProfile, CompanyProfileSocial, User from app.domain.models import CompanyProfile, CompanyProfileSocial, User
from app.domain.entities.company_profiles import CompanyProfileCreate, CompanyProfileRead from app.domain.entities.company_profiles import CompanyProfileCreate, CompanyProfileRead, CompanyProfileUpdate
class CompanyProfilesService: class CompanyProfilesService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.company_profiles_repository = CompanyProfilesRepository(db)
self.repo = CompanyProfilesRepository(db) self.industries_repository = IndustriesRepository(db)
self.industry_repo = IndustriesRepository(db)
async def get_company_by_creator_id(self, user_id: int) -> Optional[CompanyProfileRead]: async def get_company_by_creator_id(self, user_id: int) -> Optional[CompanyProfileRead]:
""" company_profile = await self.company_profiles_repository.get_company_by_creator_id(user_id)
Получить профиль компании по ID создателя со всеми связями.
""" if not company_profile:
query = ( raise HTTPException(
select(CompanyProfile) status_code=status.HTTP_404_NOT_FOUND,
.filter_by(creator_user_id=user_id) detail="У вас нет созданной компании"
.options(
joinedload(CompanyProfile.logo),
joinedload(CompanyProfile.official_photo),
joinedload(CompanyProfile.industry),
joinedload(CompanyProfile.socials)
) )
)
result = await self.db.execute(query)
profile = result.scalars().first()
if not profile: return CompanyProfileRead.model_validate(company_profile)
return None
return CompanyProfileRead.model_validate(profile)
async def create_company(self, data: CompanyProfileCreate, user: User) -> CompanyProfileRead: async def create_company(self, data: CompanyProfileCreate, user: User) -> CompanyProfileRead:
""" existing_profile = await self.company_profiles_repository.get_company_by_creator_id(user.id)
Создать новый профиль компании.
"""
# Проверка: нет ли уже профиля у этого пользователя
existing_profile = await self.get_company_by_creator_id(user.id)
if existing_profile: if existing_profile:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="У вас уже создан профиль компании. Используйте обновление." detail="У вас уже создан профиль компании. Используйте обновление."
) )
# Проверка существования индустрии industry = await self.industries_repository.get_by_id(data.industry_id)
industry = await self.industry_repo.get_by_id(data.industry_id)
if not industry: if not industry:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Указанная индустрия не найдена" detail="Указанная индустрия не найдена"
) )
# Создание объекта модели
new_company = CompanyProfile( new_company = CompanyProfile(
title=data.title, title=data.title,
description=data.description, description=data.description,
@ -70,23 +53,50 @@ class CompanyProfilesService:
official_photo_id=data.official_photo_id, official_photo_id=data.official_photo_id,
industry_id=data.industry_id, industry_id=data.industry_id,
creator_user_id=user.id, creator_user_id=user.id,
is_verified=False
) )
# Сохранение в БД через репозиторий created_company = await self.company_profiles_repository.create(new_company)
created_company = await self.repo.create(new_company)
# Добавление социальных сетей, если они переданы result = await self.company_profiles_repository.get_by_id(created_company.id)
if data.socials:
social_models = [ return CompanyProfileRead.model_validate(result)
CompanyProfileSocial(
title=s.title, async def update_company(self, company_id: int, data: CompanyProfileUpdate, current_user: User) -> CompanyProfileRead:
link=s.link, existing_profile = await self.company_profiles_repository.get_by_id(company_id)
company_id=created_company.id if not existing_profile:
) for s in data.socials raise HTTPException(
] status_code=status.HTTP_400_BAD_REQUEST,
await self.repo.add_socials(social_models) detail="Компания с таким ID не найдена."
)
industry = await self.industries_repository.get_by_id(data.industry_id)
if not industry:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Указанная индустрия не найдена"
)
if existing_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
existing_profile.title = data.title
existing_profile.description = data.description
existing_profile.website_url = data.website_url
existing_profile.inn = data.inn
existing_profile.corporate_email = str(data.corporate_email) if data.corporate_email else None
existing_profile.video_url = data.video_url
existing_profile.logo_id = data.logo_id
existing_profile.official_photo_id = data.official_photo_id
existing_profile.industry_id = data.industry_id
existing_profile.creator_user_id = current_user.id
existing_profile = await self.company_profiles_repository.update(existing_profile)
result = await self.company_profiles_repository.get_by_id(existing_profile.id)
# Возвращаем полный объект профиля (с подгруженными связями)
result = await self.repo.get_by_id(created_company.id)
return CompanyProfileRead.model_validate(result) return CompanyProfileRead.model_validate(result)

View File

@ -0,0 +1,80 @@
from typing import List
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.company_profiles_repository import CompanyProfilesRepository
from app.application.verification_requests_repository import VerificationRequestSRepository
from app.core.constants import UserRoles
from app.domain.entities.verification_requests import VerificationRequestRead
from app.domain.models import User, VerificationRequest
class CompanyVerificationRequestsService:
def __init__(self, db: AsyncSession):
self.verification_requests_repository = VerificationRequestSRepository(db)
self.company_profiles_repository = CompanyProfilesRepository(db)
async def get_by_company_id(self, company_id: int, current_user: User) -> List[VerificationRequestRead]:
company_profile = await self.company_profiles_repository.get_by_id(company_id)
if not company_profile:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Компания с таким ID не найдена."
)
if company_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
requests = await self.verification_requests_repository.get_by_company_profile_id(company_id)
response = []
for request in requests:
response.append(VerificationRequestRead.model_validate(request))
return response
async def create_verification_request(self, company_id: int, current_user: User) -> VerificationRequestRead:
company_profile = await self.company_profiles_repository.get_by_id(company_id)
if not company_profile:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Компания с таким ID не найдена."
)
if company_profile.creator_user_id != current_user.id and not (
current_user.role.title == UserRoles.MODERATOR and current_user.is_admin
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Доступ запрещен',
)
if company_profile.is_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Компания с таким ID уже верифицирована."
)
old_requests = await self.verification_requests_repository.get_by_company_profile_id(company_id)
for old_request in old_requests:
if old_request.is_accepted is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="У компании уже есть активная заявка на верификацию"
)
new_verification_request_model = VerificationRequest(
company_id=company_id,
)
new_verification_request_model = await self.verification_requests_repository.create(
new_verification_request_model
)
return VerificationRequestRead.model_validate(new_verification_request_model)

View File

@ -1,41 +1,42 @@
import os import os
import uuid import uuid
import aiofiles import aiofiles
from app.application.files_repository import FilesRepository from fastapi import UploadFile
from werkzeug.utils import secure_filename
class FilesService: class FilesService:
def __init__(self, db): async def save_file(self, file: UploadFile, upload_dir) -> str:
self.repo = FilesRepository(db) os.makedirs(upload_dir, exist_ok=True)
# Базовый путь относительно корня проекта filename = self.generate_filename(file)
self.base_dir = "База данных/media" file_path = os.path.join(upload_dir, filename)
async def _save_to_disk(self, upload_file, sub_folder: str): async with aiofiles.open(file_path, 'wb') as out_file:
# Генерируем уникальное имя, чтобы не перезаписать файлы content = await file.read()
unique_name = f"{uuid.uuid4()}_{upload_file.filename}" await out_file.write(content)
relative_folder = os.path.join(sub_folder) return file_path
full_folder_path = os.path.join(self.base_dir, relative_folder)
os.makedirs(full_folder_path, exist_ok=True) @staticmethod
def generate_filename(file: UploadFile) -> str:
return secure_filename(f"{uuid.uuid4()}_{file.filename}")
file_path = os.path.join(full_folder_path, unique_name) @staticmethod
# URL для раздачи статикой def get_media_type(filename: str) -> str:
web_url = f"/media/{sub_folder}/{unique_name}" extension = filename.split('.')[-1].lower()
if extension in ['jpeg', 'jpg', 'png']:
return f"image/{extension}"
if extension == 'pdf':
return "application/pdf"
if extension in ['zip']:
return "application/zip"
if extension in ['doc', 'docx']:
return "application/msword"
if extension in ['xls', 'xlsx']:
return "application/vnd.ms-excel"
if extension in ['ppt', 'pptx']:
return "application/vnd.ms-powerpoint"
if extension in ['txt']:
return "text/plain"
content = await upload_file.read() return "application/octet-stream"
async with aiofiles.open(file_path, mode='wb') as f:
await f.write(content)
return {
"filename": upload_file.filename,
"path": file_path,
"url": web_url
}
async def upload_logo(self, file):
info = await self._save_to_disk(file, "company_logos")
return await self.repo.create_logo_record(**info)
async def upload_photo(self, file):
info = await self._save_to_disk(file, "company_photos")
return await self.repo.create_photo_record(**info)

View File

@ -0,0 +1,22 @@
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.industries_repository import IndustriesRepository
from app.domain.entities.industries import IndustryRead
class IndustriesService:
def __init__(self, db: AsyncSession):
self.industries_repository = IndustriesRepository(db)
async def get_all(self) -> List[IndustryRead]:
industries = await self.industries_repository.get_all()
response = []
for industry in industries:
response.append(IndustryRead.model_validate(
industry
))
return response

View File

@ -1,5 +1,4 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
@ -8,16 +7,20 @@ from app.controllers.applicant_profiles_router import applicant_profiles_router
from app.controllers.applicant_skill_tags_router import applicant_skill_tags_router from app.controllers.applicant_skill_tags_router import applicant_skill_tags_router
from app.controllers.applicant_skills_router import applicant_skills_router from app.controllers.applicant_skills_router import applicant_skills_router
from app.controllers.auth_router import auth_router from app.controllers.auth_router import auth_router
from app.controllers.company_profile_logos_router import company_profile_logos_router
from app.controllers.company_profile_photos_router import company_profile_photos_router
from app.controllers.company_profile_socials_router import company_profile_socials_router
from app.controllers.company_profiles_router import company_profiles_router from app.controllers.company_profiles_router import company_profiles_router
from app.controllers.dictionaries_router import router as dictionaries_router from app.controllers.dictionaries_router import router as dictionaries_router
from app.controllers.experince_levels_router import experience_levels_router
from app.controllers.files_router import files_router
from app.controllers.industries_router import industries_router from app.controllers.industries_router import industries_router
from app.controllers.internships_router import internships_router from app.controllers.internships_router import internships_router
from app.controllers.files_router import files_router
from app.controllers.experince_levels_router import experience_levels_router
from app.controllers.roles_router import roles_router from app.controllers.roles_router import roles_router
from app.controllers.universities_router import universities_router from app.controllers.universities_router import universities_router
from app.controllers.users_router import users_router from app.controllers.users_router import users_router
from app.controllers.vacancies_router import vacancies_router from app.controllers.vacancies_router import vacancies_router
from app.controllers.verification_requests_router import verification_requests_router
from app.settings import Settings from app.settings import Settings
@ -39,8 +42,11 @@ def start_app():
api_app.include_router(applicant_skill_tags_router, prefix=f'{settings.prefix}/applicant_skill_tags', tags=['applicant_skill_tags']) api_app.include_router(applicant_skill_tags_router, prefix=f'{settings.prefix}/applicant_skill_tags', tags=['applicant_skill_tags'])
api_app.include_router(applicant_skills_router, prefix=f'{settings.prefix}/applicant_skills', tags=['applicant_skills']) api_app.include_router(applicant_skills_router, prefix=f'{settings.prefix}/applicant_skills', tags=['applicant_skills'])
api_app.include_router(auth_router, prefix=f'{settings.prefix}/auth', tags=['auth']) api_app.include_router(auth_router, prefix=f'{settings.prefix}/auth', tags=['auth'])
api_app.include_router(company_profile_logos_router, prefix=f'{settings.prefix}/company_profile_logos', tags=['company_profile_logos'])
api_app.include_router(company_profile_photos_router, prefix=f'{settings.prefix}/company_profile_photos', tags=['company_profile_photos'])
api_app.include_router(company_profile_socials_router, prefix=f'{settings.prefix}/company_profile_socials', tags=['company_profile_socials'])
api_app.include_router(company_profiles_router, prefix=f'{settings.prefix}/company_profiles', tags=['company_profiles']) api_app.include_router(company_profiles_router, prefix=f'{settings.prefix}/company_profiles', tags=['company_profiles'])
api_app.include_router(industries_router, prefix=f'{settings.prefix}/company_profiles/industries', tags=['company_profiles']) api_app.include_router(industries_router, prefix=f'{settings.prefix}/industries', tags=['company_profiles'])
api_app.include_router(internships_router, prefix=f'{settings.prefix}/internships', tags=["internships"]) api_app.include_router(internships_router, prefix=f'{settings.prefix}/internships', tags=["internships"])
api_app.include_router(files_router, prefix=f'{settings.prefix}/company_profiles/files', tags=['company_profiles']) api_app.include_router(files_router, prefix=f'{settings.prefix}/company_profiles/files', tags=['company_profiles'])
api_app.include_router(dictionaries_router, prefix=f'{settings.prefix}/vacancies/dictionaries', tags=['vacancies']) api_app.include_router(dictionaries_router, prefix=f'{settings.prefix}/vacancies/dictionaries', tags=['vacancies'])
@ -49,6 +55,7 @@ def start_app():
api_app.include_router(universities_router, prefix=f'{settings.prefix}/universities', tags=['universities']) api_app.include_router(universities_router, prefix=f'{settings.prefix}/universities', tags=['universities'])
api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users']) api_app.include_router(users_router, prefix=f'{settings.prefix}/users', tags=['users'])
api_app.include_router(vacancies_router, prefix=f'{settings.prefix}/vacancies', tags=['vacancies']) api_app.include_router(vacancies_router, prefix=f'{settings.prefix}/vacancies', tags=['vacancies'])
api_app.include_router(verification_requests_router, prefix=f'{settings.prefix}/verification_requests', tags=['verification_requests'])
return api_app return api_app

View File

@ -1,7 +1,6 @@
import {fetchBaseQuery} from '@reduxjs/toolkit/query/react'; import {fetchBaseQuery} from '@reduxjs/toolkit/query/react';
import {logout} from '../Redux/Slices/authSlice.js'; import {logout} from '../Redux/Slices/authSlice.js';
import CONFIG from "../Core/сonfig.js"; import CONFIG from "../Core/сonfig.js";
import {notification} from "antd";
export const baseQuery = fetchBaseQuery({ export const baseQuery = fetchBaseQuery({
baseUrl: CONFIG.BASE_URL, baseUrl: CONFIG.BASE_URL,

View File

@ -1,16 +1,16 @@
import { createApi } from '@reduxjs/toolkit/query/react'; import {createApi} from '@reduxjs/toolkit/query/react';
import { baseQueryWithAuth } from "./baseQuery.js"; import {baseQueryWithAuth} from "./baseQuery.js";
export const companyApi = createApi({ export const companyApi = createApi({
reducerPath: 'companyApi', reducerPath: 'companyApi',
baseQuery: baseQueryWithAuth, // Просто ссылка на функцию baseQuery: baseQueryWithAuth,
tagTypes: ['Company'], tagTypes: ['Company', 'CompanyProfileLogos', 'CompanyProfilePhotos'],
endpoints: (builder) => ({ endpoints: (builder) => ({
getProfile: builder.query({ getCompanyProfile: builder.query({
query: () => '/company_profiles/me', query: () => '/company_profiles/me/',
providesTags: ['Company'], providesTags: ['Company'],
}), }),
createOrUpdateCompany: builder.mutation({ createCompanyProfile: builder.mutation({
query: (data) => ({ query: (data) => ({
url: '/company_profiles/', url: '/company_profiles/',
method: 'POST', method: 'POST',
@ -18,7 +18,69 @@ export const companyApi = createApi({
}), }),
invalidatesTags: ['Company'], invalidatesTags: ['Company'],
}), }),
updateCompanyProfile: builder.mutation({
query: ({companyProfileId, data}) => ({
url: `/company_profiles/${companyProfileId}/`,
method: 'PUT',
body: data,
}),
invalidatesTags: ['Company'],
}),
uploadCompanyProfileLogo: builder.mutation({
query: ({companyProfileId, fileData}) => {
if (!(fileData instanceof File)) {
throw new Error('Invalid file object');
}
const formData = new FormData();
formData.append('file', fileData);
return {
url: `/company_profile_logos/files/${companyProfileId}/upload/`,
method: 'POST',
formData: true,
body: formData,
};
},
invalidatesTags: ["CompanyProfileLogos", "Company"],
}),
deleteCompanyProfileLogo: builder.mutation({
query: (fileId) => ({
url: `/company_profile_logos/files/${fileId}/`,
method: "DELETE",
}),
invalidatesTags: ["CompanyProfileLogos", "Company"],
}),
uploadCompanyProfilePhoto: builder.mutation({
query: ({companyProfileId, fileData}) => {
if (!(fileData instanceof File)) {
throw new Error('Invalid file object');
}
const formData = new FormData();
formData.append('file', fileData);
return {
url: `/company_profile_photos/files/${companyProfileId}/upload/`,
method: 'POST',
formData: true,
body: formData,
};
},
invalidatesTags: ["CompanyProfilePhotos", "Company"],
}),
deleteCompanyProfilePhoto: builder.mutation({
query: (fileId) => ({
url: `/company_profile_photos/files/${fileId}/`,
method: "DELETE",
}),
invalidatesTags: ["CompanyProfilePhotos", "Company"],
}),
}), }),
}); });
export const { useGetProfileQuery, useCreateOrUpdateCompanyMutation } = companyApi; export const {
useGetCompanyProfileQuery,
useCreateCompanyProfileMutation,
useUpdateCompanyProfileMutation,
useUploadCompanyProfileLogoMutation,
useDeleteCompanyProfileLogoMutation,
useUploadCompanyProfilePhotoMutation,
useDeleteCompanyProfilePhotoMutation,
} = companyApi;

View File

@ -0,0 +1,28 @@
import {createApi} from "@reduxjs/toolkit/query/react";
import {baseQueryWithAuth} from "./baseQuery.js";
export const companyProfileSocialsApi = createApi({
reducerPath: "companyProfileSocialsApi",
baseQuery: baseQueryWithAuth,
tagTypes: ["CompanyProfileSocials"],
endpoints: (builder) => ({
getCompanyProfileSocialsByCompanyProfileId: builder.query({
query: (companyProfileId) => `/company_profile_socials/${companyProfileId}/`,
providesTags: ["CompanyProfileSocials"],
}),
replaceCompanyProfileSocials: builder.mutation({
query: ({companyProfileId, companyProfileSocials}) => ({
url: `/company_profile_socials/${companyProfileId}/`,
method: "POST",
body: companyProfileSocials,
}),
invalidatesTags: ["CompanyProfileSocials"],
}),
}),
});
export const {
useGetCompanyProfileSocialsByCompanyProfileIdQuery,
useReplaceCompanyProfileSocialsMutation
} = companyProfileSocialsApi;

View File

@ -4,14 +4,11 @@ import { baseQueryWithAuth } from "./baseQuery.js";
export const industriesApi = createApi({ export const industriesApi = createApi({
reducerPath: 'industriesApi', reducerPath: 'industriesApi',
baseQuery: baseQueryWithAuth, baseQuery: baseQueryWithAuth,
tagTypes: ['industries'],
endpoints: (builder) => ({ endpoints: (builder) => ({
getAllIndustries: builder.query({ getAllIndustries: builder.query({
query: () => '/company_profiles/industries/', query: () => '/industries/',
transformResponse: (response) => providesTags: ['industries'],
response.map(item => ({
label: item.title,
value: item.id
})),
}), }),
}), }),
}); });

View File

@ -0,0 +1,27 @@
import {createApi} from "@reduxjs/toolkit/query/react";
import {baseQueryWithAuth} from "./baseQuery.js";
export const verificationRequestsApi = createApi({
reducerPath: "verificationRequestsApi",
baseQuery: baseQueryWithAuth,
tagTypes: ["verificationRequests"],
endpoints: (builder) => ({
getVerificationRequestsByCompanyProfileId: builder.query({
query: (companyId) => `/verification_requests/${companyId}/`,
providesTags: ["verificationRequests"],
}),
createVerificationRequest: builder.mutation({
query: (companyId) => ({
url: `/verification_requests/${companyId}/`,
method: "POST",
}),
injectTags: ["verificationRequests"],
}),
}),
});
export const {
useGetVerificationRequestsByCompanyProfileIdQuery,
useCreateVerificationRequestMutation,
} = verificationRequestsApi;

View File

@ -73,13 +73,15 @@ const AppLayout = () => {
onClick={handleMenuClick} onClick={handleMenuClick}
/> />
<Tooltip title="Избранное"> {user?.role?.title === ROLES.APPLICANT && (
<Button type="text" onClick={handleFavoritesClick} style={{paddingInline: 10}}> <Tooltip title="Избранное">
<Badge count={favoritesCount} overflowCount={99} size="small"> <Button type="text" onClick={handleFavoritesClick} style={{paddingInline: 10}}>
<HeartOutlined style={{fontSize: 18}}/> <Badge count={favoritesCount} overflowCount={99} size="small">
</Badge> <HeartOutlined style={{fontSize: 18}}/>
</Button> </Badge>
</Tooltip> </Button>
</Tooltip>
)}
{user ? ( {user ? (
<Space <Space

View File

@ -1,76 +1,47 @@
import { useEffect, useMemo } from 'react'; import {useEffect, useMemo} from 'react';
import { Form, notification } from 'antd'; import {Form, notification} from 'antd';
import useEditor from "../../../../../../../Hook/useEditor.js"; import useEditor from "../../../../../../../Hook/useEditor.js";
import { useSelector } from "react-redux"; import {useSelector} from "react-redux";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useGetAllUniversitiesQuery } from "../../../../../../../Api/universitiesApi.js"; import {useGetAllUniversitiesQuery} from "../../../../../../../Api/universitiesApi.js";
import { import {
useGetApplicantEducationsByApplicantIdQuery, useGetApplicantEducationsByApplicantIdQuery,
useReplaceApplicantEducationsMutation useReplaceApplicantEducationsMutation
} from "../../../../../../../Api/applicantEducationsApi.js"; } from "../../../../../../../Api/applicantEducationsApi.js";
import { useUpdateUserMutation } from "../../../../../../../Api/usersApi.js"; import {useUpdateUserMutation} from "../../../../../../../Api/usersApi.js";
import { useUpdateApplicantProfileMutation } from "../../../../../../../Api/applicantProfilesApi.js"; import {useUpdateApplicantProfileMutation} from "../../../../../../../Api/applicantProfilesApi.js";
import { import {
useGetApplicantSkillsByApplicantIdQuery, useGetApplicantSkillsByApplicantIdQuery,
useReplaceApplicantSkillsMutation useReplaceApplicantSkillsMutation
} from "../../../../../../../Api/applicantSkillsApi.js"; } from "../../../../../../../Api/applicantSkillsApi.js";
import { useGetAllApplicantSkillTagsQuery } from "../../../../../../../Api/applicantSkillTagsApi.js"; import {useGetAllApplicantSkillTagsQuery} from "../../../../../../../Api/applicantSkillTagsApi.js";
import { useGetAllExperienceLevelsQuery } from "../../../../../../../Api/experienceLevelsApi.js"; import {useGetAllExperienceLevelsQuery} from "../../../../../../../Api/experienceLevelsApi.js";
const getStorageKey = (userId) => userId ? `profile_draft_user_${userId}` : null;
const useProfileTab = () => { const useProfileTab = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const { editorRef, joditConfig, getContent, setContent } = useEditor(); const {editorRef, joditConfig, getContent, setContent} = useEditor();
const { userData } = useSelector((state) => state.auth); const {userData} = useSelector((state) => state.auth);
const { data: universities = [] } = useGetAllUniversitiesQuery(); const {data: universities = []} = useGetAllUniversitiesQuery();
const { data: skillTags = [] } = useGetAllApplicantSkillTagsQuery(); const {data: skillTags = []} = useGetAllApplicantSkillTagsQuery();
const { data: experienceLevels = [] } = useGetAllExperienceLevelsQuery(); const {data: experienceLevels = []} = useGetAllExperienceLevelsQuery();
const userId = userData?.id; const userId = userData?.id;
const applicantId = userData?.applicant_profile?.id; const applicantId = userData?.applicant_profile?.id;
const currentStorageKey = useMemo(() => getStorageKey(userId), [userId]);
const { data: educationsData = [] } = useGetApplicantEducationsByApplicantIdQuery(applicantId, { skip: !applicantId }); const {data: educationsData = []} = useGetApplicantEducationsByApplicantIdQuery(applicantId, {skip: !applicantId});
const { data: skillsData = [] } = useGetApplicantSkillsByApplicantIdQuery(applicantId, { skip: !applicantId }); const {data: skillsData = []} = useGetApplicantSkillsByApplicantIdQuery(applicantId, {skip: !applicantId});
const [updateUser, { isLoading: updatingUser }] = useUpdateUserMutation();
const [replaceEducations, { isLoading: updatingEducations }] = useReplaceApplicantEducationsMutation();
const [replaceSkills, { isLoading: updatingSkills }] = useReplaceApplicantSkillsMutation();
const [updateApplicantProfile, { isLoading: updatingApplicantProfile }] = useUpdateApplicantProfileMutation();
const [updateUser, {isLoading: updatingUser}] = useUpdateUserMutation();
const [replaceEducations, {isLoading: updatingEducations}] = useReplaceApplicantEducationsMutation();
const [replaceSkills, {isLoading: updatingSkills}] = useReplaceApplicantSkillsMutation();
const [updateApplicantProfile, {isLoading: updatingApplicantProfile}] = useUpdateApplicantProfileMutation();
useEffect(() => { useEffect(() => {
if (!currentStorageKey) return; if (userData) {
const values = {...userData, resume_url: userData?.applicant_profile?.resume_url};
const saved = localStorage.getItem(currentStorageKey);
if (saved) {
try {
const draft = JSON.parse(saved);
const restoredValues = { ...draft };
if (restoredValues.birthdate) {
restoredValues.birthdate = dayjs(restoredValues.birthdate);
}
form.setFieldsValue(restoredValues);
if (restoredValues.resume_html) {
setContent(restoredValues.resume_html);
}
console.log("Данные восстановлены из локального черновика пользователя:", userId);
} catch (e) {
console.error("Ошибка парсинга локальных данных", e);
}
}
}, [currentStorageKey, form, setContent, userId]);
useEffect(() => {
const hasDraft = currentStorageKey ? localStorage.getItem(currentStorageKey) : null;
if (userData && !hasDraft) {
const values = { ...userData, resume_url: userData?.applicant_profile?.resume_url };
if (values.birthdate) { if (values.birthdate) {
values.birthdate = dayjs(values.birthdate); values.birthdate = dayjs(values.birthdate);
@ -95,7 +66,7 @@ const useProfileTab = () => {
setContent(userData.applicant_profile.resume_html); setContent(userData.applicant_profile.resume_html);
} }
} }
}, [userData, educationsData, skillsData, form, setContent, currentStorageKey]); }, [userData, educationsData, skillsData, form, setContent]);
const handleSave = async (values) => { const handleSave = async (values) => {
const resumeHtml = getContent(); const resumeHtml = getContent();
@ -120,48 +91,33 @@ const useProfileTab = () => {
try { try {
await Promise.all([ await Promise.all([
updateUser({ userId, ...userPayload }).unwrap(), updateUser({userId, ...userPayload}).unwrap(),
replaceEducations({ applicantId, applicantEducations: values.educations || [] }).unwrap(), replaceEducations({applicantId, applicantEducations: values.educations || []}).unwrap(),
updateApplicantProfile({ applicantId, ...applicantProfilePayload }).unwrap(), updateApplicantProfile({applicantId, ...applicantProfilePayload}).unwrap(),
replaceSkills({ applicantId, applicantSkills: skillsPayload }).unwrap(), replaceSkills({applicantId, applicantSkills: skillsPayload}).unwrap(),
]); ]);
if (currentStorageKey) {
localStorage.removeItem(currentStorageKey);
}
notification.success({ notification.success({
message: 'Успешно', message: 'Успешно',
description: 'Профиль обновлен', description: 'Профиль обновлен',
}); });
} catch (err) { } catch {
if (currentStorageKey) {
const backup = {
...values,
resume_html: resumeHtml,
birthdate: values.birthdate ? values.birthdate.toISOString() : null
};
localStorage.setItem(currentStorageKey, JSON.stringify(backup));
}
notification.success({ notification.success({
message: 'Успешно', message: 'Успешно',
description: 'Изменения сохранены', description: 'Изменения сохранены',
}); });
console.warn("Данные сохранены:", userId, );
} }
}; };
const universitiesOptions = useMemo(() => const universitiesOptions = useMemo(() =>
universities.map(i => ({ value: i.id, label: i.title })), [universities] universities.map(i => ({value: i.id, label: i.title})), [universities]
); );
const applicantSkillTagsOptions = useMemo(() => const applicantSkillTagsOptions = useMemo(() =>
skillTags.map(i => ({ value: i.id, label: i.title })), [skillTags] skillTags.map(i => ({value: i.id, label: i.title})), [skillTags]
); );
const experienceLevelsOptions = useMemo(() => const experienceLevelsOptions = useMemo(() =>
experienceLevels.map(i => ({ value: i.id, label: i.title })), [experienceLevels] experienceLevels.map(i => ({value: i.id, label: i.title})), [experienceLevels]
); );
return { return {

View File

@ -1,192 +1,296 @@
import React from 'react' import React from 'react'
import { import {
Button, Col, Divider, Form, Input, Row, Alert, Avatar, Badge,
Select, Skeleton, Space, Typography, Upload, Button, Col, Divider, Flex, Form, Image, Input, Row,
Select, Skeleton, Space, Spin, Tag, Typography, Upload,
} from 'antd' } from 'antd'
import { DeleteOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons' import {
DeleteOutlined,
FileImageOutlined,
InfoOutlined,
PlusOutlined,
UploadOutlined,
UserOutlined
} from '@ant-design/icons'
import useCompanyTab from './useCompanyTab' import useCompanyTab from './useCompanyTab'
import LoadingIndicator from "../../../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import CONFIG from "../../../../../../../Core/сonfig.js";
const { Title, Text } = Typography
const CompanyTab = () => { const CompanyTab = () => {
const { const {
form, verForm, isLoading, isSaving, handleSave, form,
dictionaries, verificationStatus, submitVerification, isLoading,
companyTitle, handleSave,
industriesItems,
companyData,
moderationStatus,
handleUploadCompanyProfileLogo,
handleDeleteCompanyProfileLogo,
handleUploadCompanyProfilePhoto,
handleDeleteCompanyProfilePhoto,
isSuspendForLogoShowing,
isSuspendForPhotoShowing,
verificationRequestsData,
handleCreateVerificationRequest,
} = useCompanyTab() } = useCompanyTab()
if (isLoading) return <Skeleton active paragraph={{ rows: 10 }} />
return ( return (
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Spin indicator={<LoadingIndicator/>} spinning={isLoading}>
<Divider orientation="left">Верификация компании</Divider>
<div style={{ {companyData === null && (
padding: 16, borderRadius: 10, <Alert
border: `1px solid ${verificationStatus === 'approved' ? '#b7eb8f' : verificationStatus === 'pending' ? '#ffe58f' : '#d9d9d9'}`, type="info"
background: verificationStatus === 'approved' ? '#f6ffed' : verificationStatus === 'pending' ? '#fffbe6' : '#fafafa', title={"У вас еще нет компании. Создайте ее, чтобы добавлять вакансии и другие возможности!"}
}}> />
<Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>Статус верификации компании</Title> )}
<p style={{ marginBottom: 16 }}>
<strong>Текущий статус:</strong>{' '}
<Text type={verificationStatus === 'approved' ? 'success' : 'warning'}>
{verificationStatus === 'approved' ? 'Верифицирована ✓' :
verificationStatus === 'pending' ? 'На рассмотрении' :
verificationStatus === 'rejected' ? 'Отклонена — подайте повторно' :
'Не верифицирована'}
</Text>
</p>
{verificationStatus !== 'approved' && (
<Form layout="vertical" form={verForm} onFinish={submitVerification}>
<Form.Item label="Название компании" name="company_name" rules={[{ required: true, message: 'Введите название' }]}>
<Input placeholder="Как в профиле" />
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="corporate_email" label="Корпоративная почта" rules={[{ required: true, type: 'email', message: 'Введите email' }]}>
<Input placeholder="hr@company.ru" />
</Form.Item>
</Col>
</Row>
<Form.Item name="links" label="Ссылки (сайт, соцсети)">
<Input.TextArea rows={2} placeholder="https://..." />
</Form.Item>
<Button type="primary" htmlType="submit" disabled={verificationStatus === 'pending'}>
Отправить на проверку
</Button>
</Form>
)}
</div>
<Form layout="vertical" form={form} onFinish={handleSave}> {companyData !== null && !companyData?.is_verified && !verificationRequestsData.length && (
{/* Исправлено: orientation -> titlePlacement */} <Alert
<Divider orientation="left" titlePlacement="left">Основная информация</Divider> type="info"
title={"У вас создана компания. Отправьте запрос на модерацию, чтобы вы могли создавать вакансии и другие возможности!"}
/>
)}
<Row gutter={24}> {companyData !== null && (
<Col xs={24} md={16}> <Space orientation={"vertical"}>
<Form.Item label="Наименование" name="name" rules={[{ required: true, message: 'Введите название' }]}> <Space
<Input placeholder="ООО «Ромашка»" size="large" /> orientation={"horizontal"}
</Form.Item> style={{
</Col> marginTop: "10px"
<Col xs={24} md={8}> }}
<Form.Item label="ИНН" name="inn" rules={[{ required: true, message: 'Введите ИНН' }]}> >
<Input placeholder="7707083893" size="large" /> <Typography.Text type="secondary">
</Form.Item> Статус верификации:
</Col> </Typography.Text>
</Row> <Tag
color={companyData?.is_verified ? "green" : "red"}
<Form.Item label="Краткое описание" name="about"> >
<Input.TextArea rows={4} placeholder="Расскажите о компании..." /> <Badge
</Form.Item> status={companyData?.is_verified ? "success" : "warning"}
text={moderationStatus.status}
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item label="Сфера деятельности" name="area" rules={[{ required: true, message: 'Выберите сферу деятельности' }]}>
<Select
showSearch
options={dictionaries}
placeholder="Выберите сферу"
size="large"
optionFilterProp="label"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/> />
</Form.Item> </Tag>
</Col> </Space>
<Col xs={24} md={12}> {moderationStatus.showSendRequestButton && (
<Form.Item label="Корпоративный email" name="corporate_email" rules={[{ type: 'email' }]}> <Button
<Input placeholder="hr@company.ru" size="large" /> type="primary"
</Form.Item> onClick={handleCreateVerificationRequest}
</Col> loading={isLoading}
</Row> >
Отправить запрос на верификацию компании
</Button>
)}
</Space>
)}
<Space orientation="vertical" size={16} style={{width: '100%'}}>
<Row gutter={24}> <Form layout="vertical" form={form} onFinish={handleSave}>
<Col xs={24} md={12}> <Divider orientation="left" titlePlacement="left">Основная информация</Divider>
<Form.Item label="Сайт" name="site">
<Input placeholder="https://example.com" size="large" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="Видеопрезентация (ссылка)" name="video_url">
<Input placeholder="https://youtube.com/..." size="large" />
</Form.Item>
</Col>
</Row>
<Divider orientation="left">Социальные сети</Divider> <Row gutter={24}>
<Form.List name="socials"> <Col xs={24} md={16}>
{(fields, { add, remove }) => ( <Form.Item
<> label="Наименование"
{fields.map(({ key, name, ...restField }) => ( name="title"
<Row key={key} gutter={24} align="bottom" style={{ marginBottom: 12 }}> rules={[{required: true, message: 'Введите название'}]}
<Col xs={10} md={8}> >
<Form.Item {...restField} name={[name, 'title']} <Input placeholder="ООО «Ромашка»" size="large"/>
label={name === 0 ? 'Название сети' : ''} style={{ marginBottom: 0 }}>
<Input placeholder="ВКонтакте" size="large" />
</Form.Item>
</Col>
<Col xs={11} md={14}>
<Form.Item {...restField} name={[name, 'link']}
label={name === 0 ? 'Ссылка' : ''} style={{ marginBottom: 0 }}>
<Input placeholder="https://vk.com/..." size="large" />
</Form.Item>
</Col>
<Col xs={3} md={2}>
<Button type="text" danger onClick={() => remove(name)}
icon={<DeleteOutlined />} style={{ height: 40 }} />
</Col>
</Row>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined />} block size="large">
Добавить соцсеть
</Button>
</Form.Item> </Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
label="ИНН"
name="inn"
rules={[{required: true, message: 'Введите ИНН'}]}
>
<Input placeholder="7707083893" size="large"/>
</Form.Item>
</Col>
</Row>
<Form.Item
label="Краткое описание"
name="description"
>
<Input.TextArea rows={4} placeholder="Расскажите о компании..."/>
</Form.Item>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item
label="Сфера деятельности"
name="industry_id"
rules={[{required: true, message: 'Выберите сферу деятельности'}]}
>
<Select
showSearch={{
filterOption: (input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase()),
optionFilterProp: "label"
}}
options={industriesItems}
placeholder="Выберите сферу"
size="large"
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="Корпоративный email" name="corporate_email" rules={[{type: 'email'}]}>
<Input placeholder="hr@company.ru" size="large"/>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item label="Сайт" name="website_url">
<Input placeholder="https://example.com" size="large"/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="Видеопрезентация (ссылка)" name="video_url">
<Input placeholder="https://youtube.com/..." size="large"/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left">Социальные сети</Divider>
<Form.List name="socials">
{(fields, {add, remove}) => (
<>
{fields.map(({key, name, ...restField}) => (
<Row key={key} gutter={24} align="bottom" style={{marginBottom: 12}}>
<Col xs={10} md={8}>
<Form.Item
{...restField}
name={[name, 'title']}
label={name === 0 ? 'Название сети' : ''}
style={{marginBottom: 0}}
rules={[{required: true, message: "Введите название соцсети"}]}
>
<Input placeholder="ВКонтакте" size="large"/>
</Form.Item>
</Col>
<Col xs={11} md={14}>
<Form.Item
{...restField}
name={[name, 'link']}
label={name === 0 ? 'Ссылка' : ''}
style={{marginBottom: 0}}
rules={[{required: true, message: "Вставьте ссылку"}]}
>
<Input placeholder="https://vk.com/..." size="large"/>
</Form.Item>
</Col>
<Col xs={3} md={2}>
<Button type="text" danger onClick={() => remove(name)}
icon={<DeleteOutlined/>} style={{height: 40}}/>
</Col>
</Row>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined/>} block
size="large">
Добавить соцсеть
</Button>
</Form.Item>
</>
)}
</Form.List>
{companyData !== null && companyData?.id !== undefined && (
<>
<Divider orientation="left">Медиа-файлы</Divider>
<Row gutter={24}>
<Col xs={24} md={12}>
<Space orientation="vertical">
{companyData?.logo_id && !isSuspendForLogoShowing ? (
<Image
src={`${CONFIG.BASE_URL}/company_profile_logos/file/${companyData?.logo_id}/`}
width={250}
/>
) : (
<Flex
justify={'center'}
>
<Avatar size={100} icon={<UserOutlined/>}
style={{backgroundColor: "#1890ff"}}/>
</Flex>
)}
<Upload
showUploadList={false}
accept=".jpg,.jpeg,.png"
beforeUpload={async (file) => {
await handleUploadCompanyProfileLogo(companyData?.id, file);
return false;
}}
>
<Button icon={<UploadOutlined/>} size="large"
block>{companyData?.logo_id ? "Загрузить новый логотип" : "Загрузить логотип"}</Button>
</Upload>
{companyData?.logo_id && (
<Button
onClick={() => handleDeleteCompanyProfileLogo(companyData.logo_id)}
type={"primary"}
danger
block
>
Удалить логотип
</Button>
)}
</Space>
</Col>
<Col xs={24} md={12}>
<Space orientation="vertical">
{companyData?.official_photo_id && !isSuspendForPhotoShowing ? (
<Image
src={`${CONFIG.BASE_URL}/company_profile_photos/file/${companyData?.official_photo_id}/`}
width={250}
/>
) : (
<Flex
justify={'center'}
>
<Avatar size={100} icon={<FileImageOutlined/>}
style={{backgroundColor: "#1890ff"}}/>
</Flex>
)}
<Upload
showUploadList={false}
accept=".jpg,.jpeg,.png"
beforeUpload={async (file) => {
await handleUploadCompanyProfilePhoto(companyData?.id, file);
return false;
}}
>
<Button icon={<UploadOutlined/>} size="large"
block>{companyData?.official_photo_id ? "Загрузить новое фото профиля" : "Загрузить фото профиля"}</Button>
</Upload>
{companyData?.official_photo_id && (
<Button
onClick={() => handleDeleteCompanyProfilePhoto(companyData.official_photo_id)}
type={"primary"}
danger
block
>
Удалить изображение профиля
</Button>
)}
</Space>
</Col>
</Row>
</> </>
)} )}
</Form.List>
<Divider orientation="left">Медиа-файлы</Divider> <Button type="primary" htmlType="submit" loading={isLoading} size="large"
<Row gutter={24}> style={{minWidth: 200, marginTop: 24, borderRadius: 8}}>
<Col xs={24} md={12}> Сохранить изменения
<Form.Item name="logo_id" hidden><Input /></Form.Item> </Button>
<Form.Item label="Логотип" name="logo_file" valuePropName="fileList" </Form>
getValueFromEvent={e => Array.isArray(e) ? e : e?.fileList}> </Space>
<Upload action="http://localhost:5000/api/v1/company_profiles/files/upload-logo" </Spin>
headers={{ Authorization: `Bearer ${localStorage.getItem('access_token')}` }}
onChange={(info) => {
if (info.file.status === 'done') {
form.setFieldValue('logo_id', info.file.response?.id);
}
}}
listType="picture" maxCount={1} accept=".jpg,.jpeg,.png">
<Button icon={<UploadOutlined />} size="large" block>Загрузить логотип</Button>
</Upload>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="official_photo_id" hidden><Input /></Form.Item>
<Form.Item label="Фото офиса" name="photo_file" valuePropName="fileList"
getValueFromEvent={e => Array.isArray(e) ? e : e?.fileList}>
<Upload action="http://localhost:5000/api/v1/company_profiles/files/upload-photo"
headers={{ Authorization: `Bearer ${localStorage.getItem('access_token')}` }}
onChange={(info) => {
if (info.file.status === 'done') {
form.setFieldValue('official_photo_id', info.file.response?.id);
}
}}
listType="picture" maxCount={1} accept=".jpg,.jpeg,.png">
<Button icon={<UploadOutlined />} size="large" block>Загрузить фото</Button>
</Upload>
</Form.Item>
</Col>
</Row>
<Button type="primary" htmlType="submit" loading={isSaving} size="large" style={{ minWidth: 200, marginTop: 24, borderRadius: 8 }}>
Сохранить изменения
</Button>
</Form>
</Space>
) )
} }

View File

@ -1,73 +1,155 @@
import { useEffect, useMemo } from 'react'; import {useEffect, useMemo, useState} from 'react';
import { Form, notification } from 'antd'; import {Form, notification} from 'antd';
import { useSelector } from 'react-redux';
import { useGetProfileQuery, useCreateOrUpdateCompanyMutation } from "../../../../../../../Api/companyApi";
import { useGetAllIndustriesQuery } from "../../../../../../../Api/industriesApi";
import { import {
submitEmployerVerification, useGetCompanyProfileQuery,
getEmployerVerificationStatus, useCreateCompanyProfileMutation,
} from "../../../../../../../local/tramplinStore.js"; useUpdateCompanyProfileMutation, useUploadCompanyProfileLogoMutation, useDeleteCompanyProfileLogoMutation,
useUploadCompanyProfilePhotoMutation, useDeleteCompanyProfilePhotoMutation
} from "../../../../../../../Api/companyApi";
import {useGetAllIndustriesQuery} from "../../../../../../../Api/industriesApi";
import {
useGetCompanyProfileSocialsByCompanyProfileIdQuery, useReplaceCompanyProfileSocialsMutation
} from "../../../../../../../Api/companyProfileSocialsApi.js";
import {
useCreateVerificationRequestMutation,
useGetVerificationRequestsByCompanyProfileIdQuery
} from "../../../../../../../Api/verificationRequestsApi.js";
const useCompanyTab = () => { const useCompanyTab = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [verForm] = Form.useForm(); const [isSuspendForLogoShowing, setIsSuspendForLogoShowing] = useState(false);
const { userData } = useSelector((s) => s.auth); const [isSuspendForPhotoShowing, setIsSuspendForPhotoShowing] = useState(false);
const { data: industries = [], isLoading: isDictLoading } = useGetAllIndustriesQuery(); const {
const { data: companyData, isLoading: isFetching, refetch } = useGetProfileQuery(); data: industries = [],
const [saveCompany, { isLoading: isSaving }] = useCreateOrUpdateCompanyMutation(); isLoading: isIndustriesLoading,
} = useGetAllIndustriesQuery(undefined, {
pullingInterval: 10000,
});
const {
data: companyData = null,
isLoading: isMyCompanyLoading,
} = useGetCompanyProfileQuery();
const [
createCompanyProfile,
{isLoading: isCreating}
] = useCreateCompanyProfileMutation();
const [
updateCompanyProfile,
{isLoading: isUpdating}
] = useUpdateCompanyProfileMutation();
const dictionaries = industries; const {
data: socialsData = [],
isLoading: isSocialDataLoading,
} = useGetCompanyProfileSocialsByCompanyProfileIdQuery(
companyData?.id,
{
skip: !companyData?.id,
}
);
const {
data: verificationRequestsData = [],
isLoading: isVerificationRequestsDataLoading,
} = useGetVerificationRequestsByCompanyProfileIdQuery(
companyData?.id,
{
skip: !companyData?.id,
pollingInterval: 10000,
}
);
const [
createVerificationRequest,
{isLoading: isCreatingVerificationRequest}
] = useCreateVerificationRequestMutation();
const [
uploadCompanyProfileLogo,
{isLoading: isUploadingCompanyProfileLogo}
] = useUploadCompanyProfileLogoMutation();
const [
deleteCompanyProfileLogo,
{isLoading: isDeletingCompanyProfileLogo}
] = useDeleteCompanyProfileLogoMutation();
const [
uploadCompanyProfilePhoto,
{isLoading: isUploadingCompanyProfilePhoto}
] = useUploadCompanyProfilePhotoMutation();
const [
deleteCompanyProfilePhoto,
{isLoading: isDeletingCompanyProfilePhoto}
] = useDeleteCompanyProfilePhotoMutation();
const [
replaceCompanyProfileSocials,
{isLoading: isReplacingCompanyProfileSocials}
] = useReplaceCompanyProfileSocialsMutation();
const getCompanyModerationStatus = () => {
if (companyData?.is_verified) {
return {
status: "Компания верифицирована",
showSendRequestButton: false,
};
} else if (!verificationRequestsData.length) {
return {
status: "Необходимо отправить заявку на верификацию",
showSendRequestButton: true,
};
} else {
const isAllRejected = verificationRequestsData.filter((verification_request) => (
!verification_request.is_accepted && verification_request.is_accepted !== null
));
if (isAllRejected.length) {
return {
status: "Заявка на модерацию отклонена",
showSendRequestButton: true,
};
} else {
return {
status: "Ожидание модерации компании",
showSendRequestButton: false,
};
}
}
};
const moderationStatus = useMemo(getCompanyModerationStatus, [companyData, verificationRequestsData])
useEffect(() => { useEffect(() => {
if (companyData) { if (companyData) {
form.setFieldsValue({ const values = {
name: companyData.title, title: companyData.title,
inn: companyData.inn, inn: companyData.inn,
about: companyData.description, description: companyData.description,
area: companyData.industry_id ? Number(companyData.industry_id) : undefined, industry_id: companyData.industry_id,
corporate_email: companyData.corporate_email, corporate_email: companyData.corporate_email,
site: companyData.website_url, website_url: companyData.website_url,
video_url: companyData.video_url, video_url: companyData.video_url,
socials: companyData.socials || [],
logo_id: companyData.logo?.id,
official_photo_id: companyData.official_photo?.id,
logo_file: companyData.logo ? [{
uid: '-1',
name: companyData.logo.filename,
status: 'done',
url: `http://localhost:5000${companyData.logo.url}`,
}] : [],
photo_file: companyData.official_photo ? [{
uid: '-2',
name: companyData.official_photo.filename,
status: 'done',
url: `http://localhost:5000${companyData.official_photo.url}`,
}] : [],
});
}
}, [companyData, form, industries]);
const handleSave = async (values) => {
try {
const payload = {
title: values.name,
description: values.about,
inn: values.inn,
website_url: values.site,
corporate_email: values.corporate_email,
video_url: values.video_url,
industry_id: Number(values.area),
socials: (values.socials || []).map(s => ({ title: s.title, link: s.link })),
logo_id: values.logo_id,
official_photo_id: values.official_photo_id,
}; };
await saveCompany(payload).unwrap(); values.socials = (socialsData || []).map((social) => ({
notification.success({ message: 'Успешно', description: 'Профиль компании обновлен' }); title: social.title,
refetch(); link: social.link,
}));
form.setFieldsValue(values);
}
}, [companyData, form, industries, socialsData]);
const handleCreateVerificationRequest = async () => {
try {
await createVerificationRequest(companyData?.id);
notification.success({
message: 'Успешно',
description: 'Заявка на верификацию отправлена'
});
} catch (error) { } catch (error) {
notification.error({ notification.error({
message: 'Ошибка', message: 'Ошибка',
@ -76,37 +158,210 @@ const useCompanyTab = () => {
} }
}; };
const verificationStatus = userData?.id != null ? getEmployerVerificationStatus(userData.id) : 'none'; const handleDeleteCompanyProfileLogo = async (companyId) => {
const submitVerification = async (values) => {
try { try {
submitEmployerVerification({ await deleteCompanyProfileLogo(companyId).unwrap();
userId: userData.id,
company: companyData?.title || values.company_name,
inn: values.inn || companyData?.inn,
corporateEmail: values.corporate_email,
links: values.links,
});
notification.success({ notification.success({
message: 'Заявка отправлена', message: 'Успешно',
description: 'Куратор проверит данные и подтвердит компанию.', description: 'Фото профиля удалено'
});
// TODO найти способ обновления логотипа
setTimeout(() => {
setIsSuspendForLogoShowing(!isSuspendForLogoShowing);
}, 3000);
} catch (error) {
notification.error({
message: 'Ошибка',
description: error.data?.detail?.[0]?.msg || error.data?.detail || 'Не удалось сохранить данные',
}); });
verForm.resetFields();
} catch (e) {
console.error(e);
} }
}; };
const handleUploadCompanyProfileLogo = async (companyProfileId, file) => {
try {
await uploadCompanyProfileLogo({
companyProfileId,
fileData: file,
}).unwrap();
notification.success({
message: 'Успешно',
description: 'Фото профиля загружено'
});
// TODO найти способ обновления логотипа
setIsSuspendForLogoShowing(true);
setTimeout(() => {
setIsSuspendForLogoShowing(false);
}, 3000);
} catch (error) {
console.error(error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
};
const handleUploadCompanyProfilePhoto = async (companyProfileId, file) => {
try {
await uploadCompanyProfilePhoto({
companyProfileId,
fileData: file,
}).unwrap();
notification.success({
message: 'Успешно',
description: 'Фото профиля загружено'
});
// TODO найти способ обновления фото профиля
setIsSuspendForPhotoShowing(true);
setTimeout(() => {
setIsSuspendForPhotoShowing(false);
}, 3000);
} catch (error) {
console.error(error);
const errorMessage = error.data?.detail
? JSON.stringify(error.data.detail, null, 2)
: JSON.stringify(error.data || error.message || "Неизвестная ошибка", null, 2);
notification.error({
title: "Ошибка загрузки файла",
description: `Не удалось загрузить файл ${file.name}: ${errorMessage}`,
placement: "topRight",
});
}
};
const handleDeleteCompanyProfilePhoto = async (companyId) => {
try {
await deleteCompanyProfilePhoto(companyId).unwrap();
notification.success({
message: 'Успешно',
description: 'Фото профиля удалено'
});
// TODO найти способ обновления логотипа
setTimeout(() => {
setIsSuspendForPhotoShowing(!isSuspendForPhotoShowing);
}, 3000);
} catch (error) {
notification.error({
message: 'Ошибка',
description: error.data?.detail?.[0]?.msg || error.data?.detail || 'Не удалось сохранить данные',
});
}
};
const handleSave = async (values) => {
try {
const companyPayload = {
title: values.title,
description: values.description,
inn: values.inn,
website_url: values.website_url,
corporate_email: values.corporate_email,
video_url: values.video_url,
industry_id: values.industry_id,
};
if (companyData) {
await handleUpdateCompanyProfile(companyPayload);
const socialsPayload = (values.socials || []).map((social) => ({
title: social.title,
link: social.link,
company_id: companyData.id,
}));
await Promise.all([
handleReplaceCompanySocials(socialsPayload, companyData.id),
]);
} else {
const resp = await handleCreateCompanyProfile(companyPayload);
const socialsPayload = (values.socials || []).map((social) => ({
title: social.title,
link: social.link,
company_id: resp?.id,
}));
await Promise.all([
handleReplaceCompanySocials(socialsPayload, resp?.id),
]);
}
} catch (error) {
notification.error({
message: 'Ошибка',
description: error.data?.detail?.[0]?.msg || error.data?.detail || 'Не удалось сохранить данные',
});
}
};
const handleUpdateCompanyProfile = async (companyPayload) => {
try {
await updateCompanyProfile({companyProfileId: companyData.id, data: companyPayload});
notification.success({
message: 'Успешно',
description: 'Профиль компании обновлен'
});
} catch (error) {
notification.error({
message: 'Ошибка',
description: error.data?.detail?.[0]?.msg || error.data?.detail || 'Не удалось сохранить данные',
});
}
};
const handleCreateCompanyProfile = async (companyPayload) => {
try {
const resp = await createCompanyProfile(companyPayload).unwrap();
notification.success({
message: 'Успешно',
description: 'Профиль компании создан'
});
return resp;
} catch (error) {
notification.error({
message: 'Ошибка',
description: error.data?.detail?.[0]?.msg || error.data?.detail || 'Не удалось сохранить данные',
});
}
};
const handleReplaceCompanySocials = async (companyProfileSocials, companyProfileId) => {
try {
await replaceCompanyProfileSocials({companyProfileId, companyProfileSocials}).unwrap();
} catch (error) {
notification.error({
message: 'Ошибка',
description: error.data?.detail?.[0]?.msg || error.data?.detail || 'Не удалось сохранить социальные сети',
});
}
};
const submitVerification = async (values) => {
// TODO
};
const industriesItems = industries.map((industry) => ({
value: industry.id,
label: industry.title,
}));
return { return {
form, form,
verForm, isLoading: isIndustriesLoading || isCreating || isMyCompanyLoading || isReplacingCompanyProfileSocials || isSocialDataLoading || isUpdating || isUploadingCompanyProfileLogo || isDeletingCompanyProfileLogo || isVerificationRequestsDataLoading || isCreatingVerificationRequest || isUploadingCompanyProfilePhoto || isDeletingCompanyProfilePhoto,
isLoading: isDictLoading || isFetching,
isSaving,
handleSave, handleSave,
dictionaries,
verificationStatus,
submitVerification, submitVerification,
companyTitle: companyData?.title, industriesItems,
companyData,
moderationStatus,
handleUploadCompanyProfileLogo,
handleDeleteCompanyProfileLogo,
handleUploadCompanyProfilePhoto,
handleDeleteCompanyProfilePhoto,
isSuspendForLogoShowing,
isSuspendForPhotoShowing,
verificationRequestsData,
handleCreateVerificationRequest,
}; };
}; };

View File

@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react' import React, {useState, useEffect} from 'react'
import { import {
Button, Empty, Flex, Popconfirm, Select, Space, Table, Tag, Typography, notification, Button, Empty, Flex, Popconfirm, Select, Space, Table, Tag, Typography, notification, Spin, Result,
} from 'antd' } from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' import {DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined} from '@ant-design/icons'
import { useSelector } from 'react-redux' import {useSelector} from 'react-redux'
import { import {
getOpportunities, getOpportunities,
@ -14,9 +14,11 @@ import {
} from '../../../../../../../local/tramplinStore.js' } from '../../../../../../../local/tramplinStore.js'
import OpportunityModal from '../OpportunityModal/OpportunityModal.jsx' import OpportunityModal from '../OpportunityModal/OpportunityModal.jsx'
import { salaryText, TYPE_COLORS } from '../../../../../HomePage/useHomePage.js' import {salaryText, TYPE_COLORS} from '../../../../../HomePage/useHomePage.js'
import useMyOpportunitiesTab from "./useMyOpportunitiesTab.js";
import LoadingIndicator from "../../../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const { Text } = Typography const {Text} = Typography
const TYPE_LABEL = { const TYPE_LABEL = {
vacancy: 'Вакансия', vacancy: 'Вакансия',
@ -27,14 +29,19 @@ const TYPE_LABEL = {
} }
const STATUS_LABELS = { const STATUS_LABELS = {
approved: { color: 'success', text: 'Опубликовано' }, approved: {color: 'success', text: 'Опубликовано'},
pending: { color: 'processing', text: 'На модерации' }, pending: {color: 'processing', text: 'На модерации'},
rejected: { color: 'error', text: 'Отклонено' }, rejected: {color: 'error', text: 'Отклонено'},
draft: { color: 'default', text: 'Черновик' }, draft: {color: 'default', text: 'Черновик'},
} }
export default function MyOpportunitiesTab() { export default function MyOpportunitiesTab() {
const { userData } = useSelector(s => s.auth) const {
companyData,
isLoading,
} = useMyOpportunitiesTab();
const {userData} = useSelector(s => s.auth)
const [opportunities, setOpportunities] = useState([]) const [opportunities, setOpportunities] = useState([])
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [editItem, setEditItem] = useState(null) const [editItem, setEditItem] = useState(null)
@ -66,7 +73,7 @@ export default function MyOpportunitiesTab() {
const handleSubmit = (values) => { const handleSubmit = (values) => {
if (editItem) { if (editItem) {
patchOpportunity(editItem.id, values) patchOpportunity(editItem.id, values)
notification.success({ message: 'Изменения сохранены' }) notification.success({message: 'Изменения сохранены'})
} else { } else {
upsertExtraOpportunity({ upsertExtraOpportunity({
...values, ...values,
@ -76,7 +83,7 @@ export default function MyOpportunitiesTab() {
moderationStatus: 'pending', moderationStatus: 'pending',
publishedAt: new Date().toISOString().slice(0, 10), publishedAt: new Date().toISOString().slice(0, 10),
}) })
notification.success({ message: 'Отправлено на модерацию' }) notification.success({message: 'Отправлено на модерацию'})
} }
setModalOpen(false) setModalOpen(false)
setEditItem(null) setEditItem(null)
@ -88,11 +95,11 @@ export default function MyOpportunitiesTab() {
localStorage.setItem('tramplin_opp_extras_v1', JSON.stringify(filteredExtras)) localStorage.setItem('tramplin_opp_extras_v1', JSON.stringify(filteredExtras))
const patches = JSON.parse(localStorage.getItem('tramplin_opp_patches_v1') || '{}') const patches = JSON.parse(localStorage.getItem('tramplin_opp_patches_v1') || '{}')
patches[id] = { ...patches[id], moderationStatus: 'rejected' } patches[id] = {...patches[id], moderationStatus: 'rejected'}
localStorage.setItem('tramplin_opp_patches_v1', JSON.stringify(patches)) localStorage.setItem('tramplin_opp_patches_v1', JSON.stringify(patches))
bumpStore() bumpStore()
notification.success({ message: 'Удалено' }) notification.success({message: 'Удалено'})
} }
const columns = [ const columns = [
@ -101,8 +108,8 @@ export default function MyOpportunitiesTab() {
key: 'title', key: 'title',
render: (_, r) => ( render: (_, r) => (
<div> <div>
<Text strong style={{ fontSize: 13 }}>{r.title}</Text><br /> <Text strong style={{fontSize: 13}}>{r.title}</Text><br/>
<Tag color={TYPE_COLORS[r.type]} style={{ fontSize: 11, margin: '2px 0 0' }}> <Tag color={TYPE_COLORS[r.type]} style={{fontSize: 11, margin: '2px 0 0'}}>
{TYPE_LABEL[r.type] || r.type} {TYPE_LABEL[r.type] || r.type}
</Tag> </Tag>
</div> </div>
@ -114,7 +121,7 @@ export default function MyOpportunitiesTab() {
key: 'city', key: 'city',
width: 110, width: 110,
render: (city, r) => ( render: (city, r) => (
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{fontSize: 12}}>
{city}{r.format === 'remote' ? ' (удал.)' : ''} {city}{r.format === 'remote' ? ' (удал.)' : ''}
</Text> </Text>
), ),
@ -123,7 +130,7 @@ export default function MyOpportunitiesTab() {
title: 'Зарплата', title: 'Зарплата',
key: 'salary', key: 'salary',
width: 130, width: 130,
render: (_, r) => <Text style={{ fontSize: 12 }}>{salaryText(r) || '—'}</Text>, render: (_, r) => <Text style={{fontSize: 12}}>{salaryText(r) || '—'}</Text>,
}, },
{ {
title: 'Статус', title: 'Статус',
@ -131,7 +138,7 @@ export default function MyOpportunitiesTab() {
key: 'status', key: 'status',
width: 130, width: 130,
render: (s) => { render: (s) => {
const st = STATUS_LABELS[s] || { color: 'default', text: s || '—' } const st = STATUS_LABELS[s] || {color: 'default', text: s || '—'}
return <Tag color={st.color}>{st.text}</Tag> return <Tag color={st.color}>{st.text}</Tag>
}, },
}, },
@ -140,7 +147,7 @@ export default function MyOpportunitiesTab() {
dataIndex: 'publishedAt', dataIndex: 'publishedAt',
key: 'date', key: 'date',
width: 100, width: 100,
render: d => <Text type="secondary" style={{ fontSize: 12 }}>{d || '—'}</Text>, render: d => <Text type="secondary" style={{fontSize: 12}}>{d || '—'}</Text>,
}, },
{ {
title: '', title: '',
@ -150,8 +157,11 @@ export default function MyOpportunitiesTab() {
<Space size={4}> <Space size={4}>
<Button <Button
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined/>}
onClick={() => { setEditItem(r); setModalOpen(true) }} onClick={() => {
setEditItem(r);
setModalOpen(true)
}}
/> />
<Popconfirm <Popconfirm
title="Удалить эту запись?" title="Удалить эту запись?"
@ -159,71 +169,88 @@ export default function MyOpportunitiesTab() {
okText="Да" okText="Да"
cancelText="Нет" cancelText="Нет"
> >
<Button size="small" danger icon={<DeleteOutlined />} /> <Button size="small" danger icon={<DeleteOutlined/>}/>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
}, },
] ]
return ( if (!companyData.is_verified) {
<div> return (
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }} wrap="wrap" gap={8}> <Result
<Space> title={"Не доступно"}
<Select subTitle={"Ваша компания еще не прошла модерацию, дождитесь результата модерации или отправьте запрос на модерацию во вкладке компания"}
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 160 }}
options={[
{ value: 'all', label: 'Все' },
{ value: 'approved', label: 'Опубликованные' },
{ value: 'pending', label: 'На модерации' },
{ value: 'rejected', label: 'Отклонённые' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={load}>Обновить</Button>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setEditItem(null); setModalOpen(true) }}
disabled={!isVerified}
>
Создать возможность
</Button>
</Flex>
{!isVerified && (
<div style={{
padding: '10px 14px', marginBottom: 16, borderRadius: 8,
background: '#fffbe6', border: '1px solid #ffe58f',
}}>
<Text type="warning">
Для публикации вакансий и стажировок требуется верификация компании.
Перейдите во вкладку «Компания», чтобы подать заявку.
</Text>
</div>
)}
{filtered.length === 0
? <Empty description="У вас пока нет активных записей." />
: <Table
dataSource={filtered}
columns={columns}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
/>
}
<OpportunityModal
open={modalOpen}
onClose={() => { setModalOpen(false); setEditItem(null) }}
onSubmit={handleSubmit}
initial={editItem}
companyName={companyName}
notificationApi={notification}
/> />
</div> );
}
return (
<Spin spinning={isLoading} indicator={<LoadingIndicator/>}>
<div>
<Flex justify="space-between" align="center" style={{marginBottom: 16}} wrap="wrap" gap={8}>
<Space>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{width: 160}}
options={[
{value: 'all', label: 'Все'},
{value: 'approved', label: 'Опубликованные'},
{value: 'pending', label: 'На модерации'},
{value: 'rejected', label: 'Отклонённые'},
]}
/>
<Button icon={<ReloadOutlined/>} onClick={load}>Обновить</Button>
</Space>
<Button
type="primary"
icon={<PlusOutlined/>}
onClick={() => {
setEditItem(null);
setModalOpen(true)
}}
disabled={!isVerified}
>
Создать возможность
</Button>
</Flex>
{!isVerified && (
<div style={{
padding: '10px 14px', marginBottom: 16, borderRadius: 8,
background: '#fffbe6', border: '1px solid #ffe58f',
}}>
<Text type="warning">
Для публикации вакансий и стажировок требуется верификация компании.
Перейдите во вкладку «Компания», чтобы подать заявку.
</Text>
</div>
)}
{filtered.length === 0
? <Empty description="У вас пока нет активных записей."/>
: <Table
dataSource={filtered}
columns={columns}
rowKey="id"
size="small"
pagination={{pageSize: 10}}
/>
}
<OpportunityModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditItem(null)
}}
onSubmit={handleSubmit}
initial={editItem}
companyName={companyName}
notificationApi={notification}
/>
</div>
</Spin>
) )
} }

View File

@ -1,32 +1,15 @@
import { useMemo, useState, useEffect } from 'react' import {useGetCompanyProfileQuery} from "../../../../../../../Api/companyApi.js";
import { useSelector } from 'react-redux'
import { getEmployerOpportunities } from '../../../../../../../local/tramplinStore.js'
const useMyOpportunitiesTab = () => { const useMyOpportunitiesTab = () => {
const { userData } = useSelector((s) => s.auth) const {
const [v, setV] = useState(0) data: companyData = null,
const [statusFilter, setStatusFilter] = useState('all') isLoading: isMyCompanyLoading,
} = useGetCompanyProfileQuery();
useEffect(() => { return {
const fn = () => setV((x) => x + 1) companyData,
window.addEventListener('tramplin-store-changed', fn) isLoading: isMyCompanyLoading,
return () => window.removeEventListener('tramplin-store-changed', fn) }
}, []) };
const opportunities = useMemo(() => {
const list = getEmployerOpportunities(userData?.id)
const mapped = list.map((o) => ({
id: o.id,
title: o.title,
kind: o.kind || (o.type === 'mentor' ? 'mentorship' : o.type),
status: o.status || 'active',
moderationStatus: o.moderationStatus || 'approved',
}))
if (statusFilter === 'all') return mapped
return mapped.filter((r) => r.status === statusFilter)
}, [userData?.id, v, statusFilter])
return { opportunities, isLoading: false, statusFilter, setStatusFilter }
}
export default useMyOpportunitiesTab export default useMyOpportunitiesTab

View File

@ -1,4 +1,4 @@
import { useGetProfileQuery } from '../../../../../../../Api/companyApi.js' import { useGetCompanyProfileQuery } from '../../../../../../../Api/companyApi.js'
import { import {
useGetWorkFormatsQuery, useGetWorkFormatsQuery,
useGetExperienceLevelsQuery, useGetExperienceLevelsQuery,
@ -31,7 +31,7 @@ function mapExpTitleToSlug(title) {
} }
const useOpportunityModal = (form, mode, kind, editingId, onClose, notificationApi) => { const useOpportunityModal = (form, mode, kind, editingId, onClose, notificationApi) => {
const { data: companyProfile } = useGetProfileQuery() const { data: companyProfile } = useGetCompanyProfileQuery()
const { data: workFormats = [] } = useGetWorkFormatsQuery() const { data: workFormats = [] } = useGetWorkFormatsQuery()
const { data: experienceLevels = [] } = useGetExperienceLevelsQuery() const { data: experienceLevels = [] } = useGetExperienceLevelsQuery()
const { userData } = useSelector((s) => s.auth) const { userData } = useSelector((s) => s.auth)

View File

@ -1,20 +1,21 @@
import React, { useEffect, useState } from 'react' import React, {useEffect, useState} from 'react'
import { Avatar, Empty, Flex, Select, Space, Table, Tag, Typography, notification } from 'antd' import {Avatar, Empty, Flex, Select, Space, Table, Tag, Typography, notification, Spin, Result} from 'antd'
import { UserOutlined } from '@ant-design/icons' import {UserOutlined} from '@ant-design/icons'
import { useSelector } from 'react-redux' import {useSelector} from 'react-redux'
import { import {
getApplicationsForEmployer,
updateApplication, updateApplication,
getOpportunities getOpportunities
} from '../../../../../../../local/tramplinStore.js' } from '../../../../../../../local/tramplinStore.js'
import useResponsesTab from "./useResponsesTab.js";
import LoadingIndicator from "../../../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
const { Text } = Typography const {Text} = Typography
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: 'Новый', label: 'Новый' }, {value: 'Новый', label: 'Новый'},
{ value: 'accepted', label: 'Принят' }, {value: 'accepted', label: 'Принят'},
{ value: 'rejected', label: 'Отклонён' }, {value: 'rejected', label: 'Отклонён'},
{ value: 'reserve', label: 'В резерве' }, {value: 'reserve', label: 'В резерве'},
] ]
const STATUS_COLORS = { const STATUS_COLORS = {
@ -26,7 +27,12 @@ const STATUS_COLORS = {
} }
export default function ResponsesTab() { export default function ResponsesTab() {
const { userData } = useSelector(s => s.auth) const {
companyData,
isLoading,
} = useResponsesTab();
const {userData} = useSelector(s => s.auth)
const [applications, setApplications] = useState([]) const [applications, setApplications] = useState([])
const [myOpps, setMyOpps] = useState([]) const [myOpps, setMyOpps] = useState([])
const [filterOpp, setFilterOpp] = useState('all') const [filterOpp, setFilterOpp] = useState('all')
@ -35,17 +41,19 @@ export default function ResponsesTab() {
const companyName = userData?.company?.title || userData?.name || '' const companyName = userData?.company?.title || userData?.name || ''
const load = () => { const load = () => {
const allOpps = getOpportunities().filter( const allOpps = getOpportunities().filter(
o => String(o.ownerUserId) === String(userData?.id) o => String(o.ownerUserId) === String(userData?.id)
) )
setMyOpps(allOpps) setMyOpps(allOpps)
const allApps = JSON.parse(localStorage.getItem('tramplin_applications_v1') || '[]') const allApps = JSON.parse(localStorage.getItem('tramplin_applications_v1') || '[]')
const myApps = allApps.filter(a => String(a.ownerUserId) === String(userData?.id)) const myApps = allApps.filter(a => String(a.ownerUserId) === String(userData?.id))
setApplications(myApps) setApplications(myApps)
} }
useEffect(() => { load() }, [userData, companyName]) useEffect(() => {
load()
}, [userData, companyName])
useEffect(() => { useEffect(() => {
const fn = () => load() const fn = () => load()
@ -64,10 +72,10 @@ export default function ResponsesTab() {
title: 'Соискатель', key: 'applicant', title: 'Соискатель', key: 'applicant',
render: (_, r) => ( render: (_, r) => (
<Flex align="center" gap={8}> <Flex align="center" gap={8}>
<Avatar icon={<UserOutlined />} size={32} /> <Avatar icon={<UserOutlined/>} size={32}/>
<div> <div>
<Text strong style={{ fontSize: 13 }}>{r.applicantName || 'Соискатель'}</Text><br /> <Text strong style={{fontSize: 13}}>{r.applicantName || 'Соискатель'}</Text><br/>
<Text type="secondary" style={{ fontSize: 11 }}> <Text type="secondary" style={{fontSize: 11}}>
{r.applicantEmail || `ID пользователя: ${r.applicantUserId}`} {r.applicantEmail || `ID пользователя: ${r.applicantUserId}`}
</Text> </Text>
</div> </div>
@ -78,20 +86,20 @@ export default function ResponsesTab() {
title: 'Вакансия', key: 'opp', title: 'Вакансия', key: 'opp',
render: (_, r) => ( render: (_, r) => (
<div> <div>
<Text style={{ fontSize: 13 }}>{r.opportunityTitle}</Text> <Text style={{fontSize: 13}}>{r.opportunityTitle}</Text>
</div> </div>
), ),
}, },
{ {
title: 'Сопр. письмо', dataIndex: 'coverLetter', key: 'letter', width: 200, title: 'Сопр. письмо', dataIndex: 'coverLetter', key: 'letter', width: 200,
render: text => ( render: text => (
<Text style={{ fontSize: 12 }} ellipsis={{ tooltip: text }}>{text || '—'}</Text> <Text style={{fontSize: 12}} ellipsis={{tooltip: text}}>{text || '—'}</Text>
), ),
}, },
{ {
title: 'Дата', dataIndex: 'date', key: 'date', width: 100, title: 'Дата', dataIndex: 'date', key: 'date', width: 100,
render: d => ( render: d => (
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{fontSize: 12}}>
{d || '—'} {d || '—'}
</Text> </Text>
), ),
@ -102,15 +110,15 @@ export default function ResponsesTab() {
<Select <Select
value={r.status || 'Новый'} value={r.status || 'Новый'}
size="small" size="small"
style={{ width: 150 }} style={{width: 150}}
options={STATUS_OPTIONS} options={STATUS_OPTIONS}
onChange={val => { onChange={val => {
updateApplication(r.id, { status: val }) updateApplication(r.id, {status: val})
notification.success({ message: 'Статус обновлён' }) notification.success({message: 'Статус обновлён'})
}} }}
optionRender={opt => ( optionRender={opt => (
<Space> <Space>
<Tag color={STATUS_COLORS[opt.value] || 'default'} style={{ marginRight: 0 }}> <Tag color={STATUS_COLORS[opt.value] || 'default'} style={{marginRight: 0}}>
{opt.label} {opt.label}
</Tag> </Tag>
</Space> </Space>
@ -120,38 +128,49 @@ export default function ResponsesTab() {
}, },
] ]
return ( if (!companyData.is_verified) {
<div> return (
<Flex gap={12} style={{ marginBottom: 16 }} wrap="wrap"> <Result
<Select title={"Не доступно"}
value={filterOpp} subTitle={"Ваша компания еще не прошла модерацию, дождитесь результата модерации или отправьте запрос на модерацию во вкладке компания"}
onChange={setFilterOpp} />
style={{ width: 220 }} );
placeholder="Фильтр по вакансии" }
options={[
{ value: 'all', label: 'Все вакансии' },
...myOpps.map(o => ({ value: String(o.id), label: o.title }))
]}
/>
<Select
value={filterStatus}
onChange={setFilterStatus}
style={{ width: 180 }}
placeholder="Фильтр по статусу"
options={[{ value: 'all', label: 'Все статусы' }, ...STATUS_OPTIONS]}
/>
</Flex>
{filtered.length === 0 return (
? <Empty description="Откликов на ваши предложения пока нет" /> <Spin spinning={isLoading} indicator={<LoadingIndicator/>}>
: <Table <div>
dataSource={filtered} <Flex gap={12} style={{marginBottom: 16}} wrap="wrap">
columns={columns} <Select
rowKey="id" value={filterOpp}
size="small" onChange={setFilterOpp}
pagination={{ pageSize: 10 }} style={{width: 220}}
/> placeholder="Фильтр по вакансии"
} options={[
</div> {value: 'all', label: 'Все вакансии'},
...myOpps.map(o => ({value: String(o.id), label: o.title}))
]}
/>
<Select
value={filterStatus}
onChange={setFilterStatus}
style={{width: 180}}
placeholder="Фильтр по статусу"
options={[{value: 'all', label: 'Все статусы'}, ...STATUS_OPTIONS]}
/>
</Flex>
{filtered.length === 0
? <Empty description="Откликов на ваши предложения пока нет"/>
: <Table
dataSource={filtered}
columns={columns}
rowKey="id"
size="small"
pagination={{pageSize: 10}}
/>
}
</div>
</Spin>
) )
} }

View File

@ -1,29 +1,15 @@
import { notification } from 'antd' import {useGetCompanyProfileQuery} from "../../../../../../../Api/companyApi.js";
import { useState } from 'react'
import { updateApplication } from '../../../../../../../local/tramplinStore.js'
function useResponsesTab(onRefresh) { const useResponsesTab = () => {
const [loading, setLoading] = useState(false) const {
data: companyData = null,
isLoading: isMyCompanyLoading,
} = useGetCompanyProfileQuery();
const handleStatusChange = async (id, newStatus) => { return {
setLoading(true) companyData,
try { isLoading: isMyCompanyLoading,
updateApplication(id, { status: newStatus })
notification.success({ message: 'Статус обновлён' })
if (onRefresh) onRefresh()
} catch (error) {
notification.error({ message: 'Не удалось обновить статус' })
} finally {
setLoading(false)
} }
}
const handleNoteBlur = async (id, note) => {
updateApplication(id, { note })
notification.success({ message: 'Заметка сохранена' })
}
return { handleStatusChange, handleNoteBlur, loading }
} }
export default useResponsesTab export default useResponsesTab

View File

@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect } from 'react'
import { Form, notification } from 'antd' import { Form, notification } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useGetProfileQuery } from '../../../../../Api/companyApi.js' import { useGetCompanyProfileQuery } from '../../../../../Api/companyApi.js'
import { getOpportunityById } from '../../../../../local/tramplinStore.js' import { getOpportunityById } from '../../../../../local/tramplinStore.js'
import { getApplicationsForEmployer } from '../../../../../local/tramplinStore.js' import { getApplicationsForEmployer } from '../../../../../local/tramplinStore.js'
@ -10,7 +10,7 @@ export const useEmployerCabinet = () => {
const [api, contextHolder] = notification.useNotification() const [api, contextHolder] = notification.useNotification()
const [form] = Form.useForm() const [form] = Form.useForm()
const { userData } = useSelector((s) => s.auth) const { userData } = useSelector((s) => s.auth)
const { data: companyProfile } = useGetProfileQuery() const { data: companyProfile } = useGetCompanyProfileQuery()
const [storeV, setStoreV] = useState(0) const [storeV, setStoreV] = useState(0)
useEffect(() => { useEffect(() => {

View File

@ -1,4 +1,4 @@
import { configureStore } from "@reduxjs/toolkit"; import {configureStore} from "@reduxjs/toolkit";
import authReducer from "./Slices/authSlice.js"; import authReducer from "./Slices/authSlice.js";
import userReducer from "./Slices/usersSlice.js"; import userReducer from "./Slices/usersSlice.js";
import {authApi} from "../Api/authApi.js"; import {authApi} from "../Api/authApi.js";
@ -10,12 +10,13 @@ import {applicantProfilesApi} from "../Api/applicantProfilesApi.js";
import {applicantSkillsApi} from "../Api/applicantSkillsApi.js"; import {applicantSkillsApi} from "../Api/applicantSkillsApi.js";
import {applicantSkillTagsApi} from "../Api/applicantSkillTagsApi.js"; import {applicantSkillTagsApi} from "../Api/applicantSkillTagsApi.js";
import {experienceLevelsApi} from "../Api/experienceLevelsApi.js"; import {experienceLevelsApi} from "../Api/experienceLevelsApi.js";
// Добавляем новые API import {companyApi} from "../Api/companyApi.js";
import { companyApi } from "../Api/companyApi.js"; import {industriesApi} from "../Api/industriesApi.js";
import { industriesApi } from "../Api/industriesApi.js"; import {vacanciesApi} from "../Api/vacanciesApi.js";
import {vacanciesApi } from "../Api/vacanciesApi.js"; import {dictionariesApi} from "../Api/dictionariesApi.js";
import {dictionariesApi } from "../Api/dictionariesApi.js"; import {internshipsApi} from '../Api/internshipsApi';
import { internshipsApi } from '../Api/internshipsApi'; // путь к твоему новому API import {companyProfileSocialsApi} from "../Api/companyProfileSocialsApi.js";
import {verificationRequestsApi} from "../Api/verificationRequestsApi.js";
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
@ -26,8 +27,11 @@ export const store = configureStore({
[usersApi.reducerPath]: usersApi.reducer, [usersApi.reducerPath]: usersApi.reducer,
[rolesApi.reducerPath]: rolesApi.reducer, [rolesApi.reducerPath]: rolesApi.reducer,
[universitiesApi.reducerPath]: universitiesApi.reducer, [universitiesApi.reducerPath]: universitiesApi.reducer,
[applicantEducationsApi.reducerPath]: applicantEducationsApi.reducer, [applicantEducationsApi.reducerPath]: applicantEducationsApi.reducer,
[applicantProfilesApi.reducerPath]: applicantProfilesApi.reducer, [applicantProfilesApi.reducerPath]: applicantProfilesApi.reducer,
[applicantSkillsApi.reducerPath]: applicantSkillsApi.reducer, [applicantSkillsApi.reducerPath]: applicantSkillsApi.reducer,
@ -36,12 +40,19 @@ export const store = configureStore({
[experienceLevelsApi.reducerPath]: experienceLevelsApi.reducer, [experienceLevelsApi.reducerPath]: experienceLevelsApi.reducer,
// Регистрируем новые редьюсеры
[companyApi.reducerPath]: companyApi.reducer, [companyApi.reducerPath]: companyApi.reducer,
[industriesApi.reducerPath]: industriesApi.reducer, [industriesApi.reducerPath]: industriesApi.reducer,
[vacanciesApi.reducerPath]: vacanciesApi.reducer, [vacanciesApi.reducerPath]: vacanciesApi.reducer,
[dictionariesApi.reducerPath]: dictionariesApi.reducer, [dictionariesApi.reducerPath]: dictionariesApi.reducer,
[internshipsApi.reducerPath]: internshipsApi.reducer, // ДОБАВИТЬ ЭТО
[internshipsApi.reducerPath]: internshipsApi.reducer,
[companyProfileSocialsApi.reducerPath]: companyProfileSocialsApi.reducer,
[verificationRequestsApi.reducerPath]: verificationRequestsApi.reducer,
}, },
middleware: (getDefaultMiddleware) => ( middleware: (getDefaultMiddleware) => (
getDefaultMiddleware().concat( getDefaultMiddleware().concat(
@ -51,15 +62,16 @@ export const store = configureStore({
universitiesApi.middleware, universitiesApi.middleware,
applicantEducationsApi.middleware, applicantEducationsApi.middleware,
applicantProfilesApi.middleware, applicantProfilesApi.middleware,
// Регистрируем новый middleware
companyApi.middleware, companyApi.middleware,
industriesApi.middleware, industriesApi.middleware,
vacanciesApi.middleware, vacanciesApi.middleware,
dictionariesApi.middleware, dictionariesApi.middleware,
internshipsApi.middleware, // ДОБАВИТЬ ЭТО internshipsApi.middleware,
applicantSkillsApi.middleware, applicantSkillsApi.middleware,
applicantSkillTagsApi.middleware, applicantSkillTagsApi.middleware,
experienceLevelsApi.middleware, experienceLevelsApi.middleware,
companyProfileSocialsApi.middleware,
verificationRequestsApi.middleware,
) )
), ),
}); });