diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index 3c4f422d..0ae78d11 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -16,6 +16,7 @@ from employees.models import ( EmployeeAction, EmployeeDepartment, RoleAssignment, + Skill, ) from employees.utils import _to_bool from rest_framework import filters, viewsets @@ -291,6 +292,35 @@ class EmployeeViewSet(viewsets.ModelViewSet): self.perform_update(serializer) return Response(serializer.data, status=200) + @action(detail=True, methods=["post"]) + def add_skill(self, request, pk=None): + """Добавить навык сотруднику. + + Доступно любому аутентифицированному пользователю. + Можно передать либо skill_id, либо name. + """ + instance = self.get_object() + skill_id = request.data.get("skill_id") + skill_name = (request.data.get("name") or "").strip() + + if not skill_id and not skill_name: + return Response( + {"detail": "Нужно передать skill_id или name"}, + status=400, + ) + + if skill_id: + try: + skill = Skill.objects.get(pk=int(skill_id)) + except (TypeError, ValueError, Skill.DoesNotExist): + return Response({"detail": "Skill не найден"}, status=404) + else: + skill, _ = Skill.objects.get_or_create(name=skill_name) + + instance.skills.add(skill) + serializer = self.get_serializer(instance) + return Response(serializer.data, status=200) + def get_serializer_context(self): ctx = super().get_serializer_context() if getattr(self, "action", None) in {"retrieve", "me"}: diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index ce4fce53..236d6147 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -342,3 +342,32 @@ def test_me_patch_updates_profile(api_client: APIClient): me.refresh_from_db() assert me.first_name == "Updated" + +def test_add_skill_allowed_for_any_authenticated_user(api_client: APIClient): + actor = make_user("actor@example.com") + target = make_user("target-skill@example.com") + skill = Skill.objects.create(name="Python") + + api_client.force_authenticate(user=actor) + url = reverse("api:v1:employees-add-skill", args=[target.pk]) + + resp = api_client.post(url, {"skill_id": skill.id}, format="json") + assert resp.status_code == status.HTTP_200_OK + + target.refresh_from_db() + assert target.skills.filter(pk=skill.pk).exists() + + +def test_add_skill_can_create_skill_by_name(api_client: APIClient): + actor = make_user("actor2@example.com") + target = make_user("target-create-skill@example.com") + + api_client.force_authenticate(user=actor) + url = reverse("api:v1:employees-add-skill", args=[target.pk]) + + resp = api_client.post(url, {"name": "TypeScript"}, format="json") + assert resp.status_code == status.HTTP_200_OK + + created = Skill.objects.get(name="TypeScript") + target.refresh_from_db() + assert target.skills.filter(pk=created.pk).exists() diff --git a/docs/frontend-style-guide.md b/docs/frontend-style-guide.md new file mode 100644 index 00000000..782c0a0b --- /dev/null +++ b/docs/frontend-style-guide.md @@ -0,0 +1,77 @@ +# Frontend Style Guide + +Короткий проектный контракт для новых экранов и рефакторинга старых. + +## Базовые поверхности + +- Внешний самостоятельный блок страницы: `app-surface` +- Внутренний блок внутри секции: `app-surface-muted` +- Не делать `card inside card inside card`, если можно обойтись одним внешним и одним внутренним уровнем. + +## Подписи секций + +- Верхние подписи карточек и секций: `app-card-caption` +- Внутренние заголовки внутри блоков: + - `text-sm font-semibold text-[var(--foreground)]` +- Не смешивать `app-card-caption` и обычный заголовок в одном уровне вложенности. + +## Кнопки + +- Primary action: + - `app-action-primary` +- Secondary action: + - `app-action-secondary` +- Danger action: + - secondary-style кнопка с red hover/focus, а не тяжелый красный прямоугольник по умолчанию +- Все add/create CTA должны выглядеть одинаково: + - иконка `Plus` + - `app-action-primary` + - `rounded-xl` + - одинаковая высота и плотность + +## Pills и бейджи + +- Нейтральный chip/filter: `app-pill` +- Активный/select/primary state: `app-pill-active` или `app-selected` +- Статусы должны быть семантическими: + - success: `app-feedback-success` + - warning: `app-feedback-warning` + - danger: `app-feedback-danger` +- Не использовать случайный синий только потому, что нужен акцент. + +## Формы + +- Текстовые поля: `app-input` +- Select: `app-select` +- Поисковая строка в шапке: pill-форма +- Обычные form fields: скругленные углы, не pill +- Не смешивать разные hover/focus-паттерны на одном экране + +## Профильные и read-only страницы + +- `/profile` и `/users/[id]`: + - hero сверху + - отдельные широкие секции ниже + - не дублировать settings-редактирование карандашами в нескольких местах +- Read-only экран: + - минимум inline editing + - одно явное действие лучше, чем несколько карандашей по карточкам + +## Таймлайны + +- Линия события цветная по типу события +- Точка только на текущем событии +- Текущее событие определяется по дате, а не по первой записи в списке + +## Контакты + +- Контактные блоки строятся как список строк внутри `app-surface-muted` +- Не делать отдельную маленькую карточку на каждый контакт +- Иконки контактов нейтральны, если нет реальной причины для акцента + +## Чего избегать + +- `bg-white`, `bg-gray-*`, `ring-gray-*`, `focus:ring-sky-*` в новых и перерабатываемых экранах +- Разных стилей add/save/delete между соседними страницами +- Декоративных badge без логики состояния +- Смешения old-gray-contract и `app-*` токенов в одном блоке diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index a820ec50..54b70f56 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -397,7 +397,7 @@ export default function ProfilePage() {
Мой профиль
{currentAction ? ( {currentAction.action_display || currentAction.action} @@ -434,7 +434,7 @@ export default function ProfilePage() { {user.position?.name || "Должность не указана"} {!!user.departments?.length && ( - + {user.departments.length === 1 ? user.departments[0].name : `${user.departments.length} отделов`} @@ -640,7 +640,7 @@ export default function ProfilePage() { type="button" onClick={() => void handleRemoveSkill(skill.id)} disabled={skillsSaving} - className="app-pill inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50" + className="app-pill inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] disabled:opacity-50" title="Удалить навык" > {skill.name} @@ -660,15 +660,15 @@ export default function ProfilePage() {Перенаправляем в профиль...
+Загрузка...
-Загрузка сотрудника...
+{error}
-{error}
+Профиль сотрудника
+ {latestAction ? ( + + {latestAction.action_display || latestAction.action} + + ) : null} ++ {contact.label} +
+ {contact.href ? ( + + {contact.value} + + ) : ( ++ {contact.value} +
+ )} ++ Контакты не указаны +
)}{person.position?.name || "—"}
- {latestAction && ( - - {latestAction.action_display || latestAction.action} - - )} - {canManageActions && ( - - )} -{person.departments[0].name}
- )} - - {currentUser && currentUser.id !== person.id && ( -{skillsError}
+ ) : null} - {person.telegram && ( -Нет контактов
+ ) : ( +Навыки не указаны
)}+ Доступные навыки +
++ Загружаем навыки... +
+ ) : null} ++ В компании +
++ {getWorkDuration(person.date_joined) || "—"} +
++ Дата найма +
++ {formatDate(person.date_joined)} +
++ День рождения +
++ {formatBirthday(person.birth_date) || "—"} +
++ Последний вход +
++ {formatDateTime(person.last_login)} +
++ {department.name} +
+ {department.role_name ? ( ++ {department.role_name} +
+ ) : null} +Навыки не указаны
- )} - - - {(person.date_joined || person.birth_date) && ( -В компании
-{getWorkDuration(person.date_joined)}
-Дата найма
-- {new Date(person.date_joined).toLocaleDateString("ru-RU")} -
-День рождения
-{formatBirthday(person.birth_date)}
-{department.name}
- {department.role_name && ( -{department.role_name}
- )} -{action.comment}
- )} + {action.comment ? ( ++ {action.comment} +
+ ) : null}