Allow authenticated project creation from procurement requests
All checks were successful
Deploy production / deploy (push) Successful in 16m12s
All checks were successful
Deploy production / deploy (push) Successful in 16m12s
This commit is contained in:
parent
f29e7413c1
commit
0bd767d56b
@ -1,9 +1,11 @@
|
||||
"""ViewSet composition for procurement requests."""
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters, permissions, viewsets
|
||||
from rest_framework import filters, permissions, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from procurement.activity import create_procurement_activity
|
||||
@ -12,12 +14,13 @@ from procurement.models import (
|
||||
ProcurementActivityObjectKind,
|
||||
ProcurementRequest,
|
||||
)
|
||||
from projects.models import Project
|
||||
from projects.models import Project, ProjectEvent, ProjectMembership
|
||||
|
||||
from ..permissions import (
|
||||
CanApproveProcurementRequest,
|
||||
CanAssignProcurementProjects,
|
||||
CanManageProcurementRequest,
|
||||
user_can_view_procurement_request,
|
||||
)
|
||||
from ..serializers import (
|
||||
ProcurementProjectsSerializer,
|
||||
@ -113,6 +116,70 @@ class ProcurementRequestViewSet(
|
||||
list(Project.objects.order_by("name", "pk").values("id", "name"))
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="create-project")
|
||||
def create_project(self, request, pk=None):
|
||||
"""Create a local project and attach only that project to a visible request.
|
||||
|
||||
This does not grant permission to change other associations or request fields.
|
||||
"""
|
||||
procurement_request = self.get_object()
|
||||
if not request.user.is_active or not user_can_view_procurement_request(
|
||||
request.user, procurement_request
|
||||
):
|
||||
raise PermissionDenied("Нет доступа к этой заявке.")
|
||||
|
||||
class NewProjectSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=255, trim_whitespace=True)
|
||||
|
||||
payload = NewProjectSerializer(data=request.data)
|
||||
payload.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
procurement_request = ProcurementRequest.objects.select_for_update().get(
|
||||
pk=procurement_request.pk
|
||||
)
|
||||
previous = list(
|
||||
procurement_request.projects.order_by("id").values("id", "name")
|
||||
)
|
||||
if len(previous) >= 50:
|
||||
raise ValidationError(
|
||||
{"projects": ["Не более 50 проектов в одной заявке."]}
|
||||
)
|
||||
project = Project.objects.create(
|
||||
name=payload.validated_data["name"],
|
||||
manager=request.user,
|
||||
created_by=request.user,
|
||||
)
|
||||
ProjectMembership.objects.create(
|
||||
project=project,
|
||||
employee=request.user,
|
||||
role="Руководитель",
|
||||
starts_on=timezone.localdate(),
|
||||
)
|
||||
ProjectEvent.objects.create(
|
||||
project=project,
|
||||
actor=request.user,
|
||||
action="Проект создан",
|
||||
)
|
||||
procurement_request.projects.add(project)
|
||||
procurement_request.save(update_fields=["updated_at"])
|
||||
create_procurement_activity(
|
||||
procurement_request,
|
||||
request.user,
|
||||
ProcurementActivityAction.UPDATED,
|
||||
object_kind=ProcurementActivityObjectKind.REQUEST,
|
||||
object_id=procurement_request.pk,
|
||||
metadata={
|
||||
"fields": ["projects"],
|
||||
"previous_projects": previous,
|
||||
"new_projects": previous
|
||||
+ [{"id": project.pk, "name": project.name}],
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
{"id": project.pk, "name": project.name},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["patch"], url_path="projects")
|
||||
def projects(self, request, pk=None):
|
||||
"""Change only project associations; usable after approval or completion."""
|
||||
@ -199,6 +266,8 @@ class ProcurementRequestViewSet(
|
||||
permission_classes = [CanApproveProcurementRequest]
|
||||
elif self.action in {"projects", "project_options"}:
|
||||
permission_classes = [CanAssignProcurementProjects]
|
||||
elif self.action == "create_project":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
elif self.action in {
|
||||
"submit",
|
||||
"set_viewed",
|
||||
|
||||
@ -31,7 +31,12 @@ from projects.models import (
|
||||
ProjectMembership,
|
||||
TimeAllocation,
|
||||
)
|
||||
from projects.permissions import can_create, can_manage, visible_projects
|
||||
from projects.permissions import (
|
||||
can_create,
|
||||
can_import_external,
|
||||
can_manage,
|
||||
visible_projects,
|
||||
)
|
||||
from projects.services.time_tracking import claim_reminder, day_data, save_day
|
||||
from tasks.access import task_board_access_q
|
||||
from tasks.application import link_task_object, unlink_task_object
|
||||
@ -84,7 +89,7 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
if not can_create(self.request.user):
|
||||
raise PermissionDenied(
|
||||
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
|
||||
"Создание проектов доступно только авторизованным активным пользователям."
|
||||
)
|
||||
with transaction.atomic():
|
||||
p = serializer.save(created_by=self.request.user)
|
||||
@ -132,6 +137,7 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
|
||||
return Response(
|
||||
{
|
||||
"can_create": can_create(request.user),
|
||||
"can_import_external": can_import_external(request.user),
|
||||
"employees": [{"id": u.pk, "name": str(u)} for u in employees],
|
||||
"manageable_projects": list(
|
||||
manageable.order_by("name").values("id", "name")[:500]
|
||||
@ -252,9 +258,9 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="from-external")
|
||||
def from_external(self, request):
|
||||
if not can_create(request.user):
|
||||
if not can_import_external(request.user):
|
||||
raise PermissionDenied(
|
||||
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
|
||||
"Импорт и привязка внешних проектов требуют специальных прав."
|
||||
)
|
||||
payload = ExternalProjectImportSerializer(data=request.data)
|
||||
payload.is_valid(raise_exception=True)
|
||||
|
||||
@ -17,7 +17,12 @@
|
||||
|
||||
## Доступ и история
|
||||
|
||||
Создание доступно администраторам и пользователям с `projects.add_project`.
|
||||
Создание доступно всем активным авторизованным пользователям.
|
||||
Создатель становится руководителем нового проекта.
|
||||
Создать проект и привязать к видимой заявке на закупку можно также из карточки
|
||||
заявки; право изменять существующие привязки этим не предоставляется.
|
||||
Импорт и привязка записей из внешней системы требуют отдельного повышенного права
|
||||
(сотрудник с правами администратора или разрешение add_project).
|
||||
Руководитель управляет своей карточкой, командой, этапами, связями и видит отчёт
|
||||
команды. Участник видит проект и собственные строки времени. Администратор видит
|
||||
все проекты. `?mine=true` ограничивает список собственными назначениями и для
|
||||
|
||||
@ -4,7 +4,19 @@ from .models import Project
|
||||
|
||||
|
||||
def can_create(user):
|
||||
return user.is_staff or user.has_perm("projects.add_project")
|
||||
return bool(user and user.is_authenticated and user.is_active)
|
||||
|
||||
|
||||
def can_import_external(user):
|
||||
"""Binding a company external record requires an explicit elevated permission."""
|
||||
return bool(
|
||||
user
|
||||
and user.is_authenticated
|
||||
and user.is_active
|
||||
and (
|
||||
user.is_staff or user.is_superuser or user.has_perm("projects.add_project")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_manage(user, project):
|
||||
|
||||
@ -127,6 +127,76 @@ def test_ordinary_requester_cannot_assign_even_to_own_request(setup):
|
||||
)
|
||||
|
||||
|
||||
def test_requester_can_create_project_from_existing_request_without_edit_rights(setup):
|
||||
requester, _, outsider, _, project_a, _, _, req = setup
|
||||
url = reverse("api:v1:procurement:procurementrequest-create-project", args=[req.pk])
|
||||
assert client().post(url, {"name": "Нельзя"}, format="json").status_code in (
|
||||
401,
|
||||
403,
|
||||
)
|
||||
assert client(outsider).post(url, {"name": "Чужой"}, format="json").status_code in (
|
||||
403,
|
||||
404,
|
||||
)
|
||||
assert client(requester).post(url, {"name": " "}, format="json").status_code == 400
|
||||
assert (
|
||||
client(requester).post(url, {"name": "А" * 256}, format="json").status_code
|
||||
== 400
|
||||
)
|
||||
|
||||
response = client(requester).post(url, {"name": " Свежий проект "}, format="json")
|
||||
assert response.status_code == 201, response.data
|
||||
project = Project.objects.get(pk=response.data["id"])
|
||||
assert project.name == "Свежий проект"
|
||||
assert project.manager_id == requester.pk
|
||||
assert project.created_by_id == requester.pk
|
||||
assert project.memberships.get().employee_id == requester.pk
|
||||
assert project.events.get().action == "Проект создан"
|
||||
assert list(req.projects.values_list("pk", flat=True)) == [project.pk]
|
||||
req.refresh_from_db()
|
||||
assert req.status == "completed"
|
||||
assert req.title == "Комплектующие"
|
||||
audit = ProcurementRequestActivity.objects.get(
|
||||
request=req, action=ProcurementActivityAction.UPDATED
|
||||
)
|
||||
assert audit.actor_id == requester.pk
|
||||
assert audit.metadata["previous_projects"] == []
|
||||
assert audit.metadata["new_projects"] == [
|
||||
{"id": project.pk, "name": "Свежий проект"}
|
||||
]
|
||||
|
||||
# Creating a project does not let the requester replace unrelated associations.
|
||||
assert (
|
||||
client(requester)
|
||||
.patch(endpoint(req), {"projects": [project_a.pk]}, format="json")
|
||||
.status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_project_creation_checks_request_visibility_and_50_project_limit(setup):
|
||||
requester, buyer, outsider, _, _, _, _, req = setup
|
||||
url = reverse("api:v1:procurement:procurementrequest-create-project", args=[req.pk])
|
||||
assert client(outsider).post(
|
||||
url, {"name": "Скрытый"}, format="json"
|
||||
).status_code in (403, 404)
|
||||
existing = [
|
||||
Project(
|
||||
name=f"Проект #{number}",
|
||||
manager=requester,
|
||||
created_by=requester,
|
||||
)
|
||||
for number in range(50)
|
||||
]
|
||||
Project.objects.bulk_create(existing)
|
||||
req.projects.set(Project.objects.filter(name__startswith="Проект #"))
|
||||
count_before = Project.objects.count()
|
||||
result = client(buyer).post(url, {"name": "Лишний"}, format="json")
|
||||
assert result.status_code == 400
|
||||
assert Project.objects.count() == count_before
|
||||
assert req.projects.count() == 50
|
||||
|
||||
|
||||
def test_dedicated_permission_allows_project_edit_but_not_other_edits(setup):
|
||||
requester, _, outsider, _, project_a, _, _, req = setup
|
||||
requester.user_permissions.add(
|
||||
|
||||
@ -65,7 +65,12 @@ def test_visibility_and_management(setup_project):
|
||||
c.force_authenticate(outsider)
|
||||
assert c.get(f"{ROOT}{p.pk}/").status_code == 404
|
||||
assert c.get(ROOT).data["count"] == 0
|
||||
assert c.post(ROOT, {"name": "Нельзя", "manager": outsider.pk}).status_code == 403
|
||||
created = c.post(
|
||||
ROOT, {"name": "Собственный проект", "manager": outsider.pk}, format="json"
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.data["can_manage"] is True
|
||||
assert c.get(f"{ROOT}{p.pk}/").status_code == 404
|
||||
c.force_authenticate(worker)
|
||||
worker_detail = c.get(f"{ROOT}{p.pk}/")
|
||||
assert worker_detail.status_code == 200
|
||||
@ -153,6 +158,35 @@ def test_creation_and_dates(setup_project):
|
||||
assert not p.bindings.exists()
|
||||
|
||||
|
||||
def test_ordinary_user_can_create_local_project_and_manage_own_project(
|
||||
setup_project,
|
||||
):
|
||||
_, worker, outsider, _, client = setup_project
|
||||
client.force_authenticate(worker)
|
||||
options = client.get(f"{ROOT}options/")
|
||||
assert options.status_code == 200
|
||||
assert options.data["can_create"] is True
|
||||
|
||||
result = client.post(
|
||||
ROOT, {"name": "Проект сотрудника", "manager": worker.pk}, format="json"
|
||||
)
|
||||
assert result.status_code == 201, result.data
|
||||
project = Project.objects.get(pk=result.data["id"])
|
||||
assert project.created_by_id == worker.pk
|
||||
assert project.manager_id == worker.pk
|
||||
assert project.memberships.get().employee_id == worker.pk
|
||||
assert project.events.get().action == "Проект создан"
|
||||
assert result.data["can_manage"] is True
|
||||
|
||||
client.force_authenticate(outsider)
|
||||
assert client.get(f"{ROOT}{project.pk}/").status_code == 404
|
||||
|
||||
client.force_authenticate(user=None)
|
||||
assert client.post(
|
||||
ROOT, {"name": "Без входа", "manager": worker.pk}, format="json"
|
||||
).status_code in (401, 403)
|
||||
|
||||
|
||||
def test_snapshots_hours_and_conflicts(setup_project):
|
||||
_, worker, _, p, c = setup_project
|
||||
c.force_authenticate(worker)
|
||||
@ -552,8 +586,11 @@ def test_project_options_only_offer_manageable_projects(setup_project):
|
||||
client.force_authenticate(worker)
|
||||
options = client.get(f"{ROOT}options/").data
|
||||
assert options["manageable_projects"] == []
|
||||
assert options["can_create"] is True
|
||||
assert options["can_import_external"] is False
|
||||
client.force_authenticate(manager)
|
||||
options = client.get(f"{ROOT}options/").data
|
||||
assert options["can_import_external"] is True
|
||||
assert {row["id"] for row in options["manageable_projects"]} == {
|
||||
project.pk,
|
||||
other.pk,
|
||||
|
||||
@ -328,3 +328,17 @@ presents this before/after information in the request history.
|
||||
|
||||
The create/edit form shows a soft reminder when a project has not yet been
|
||||
selected, but still allows saving a request without it.
|
||||
|
||||
Создание проекта в разделе «Проекты» доступно всем активным
|
||||
авторизованным сотрудникам (POST /api/v1/projects/), при создании фиксируются
|
||||
автор, руководитель и событие.
|
||||
|
||||
Из карточки видимой пользователю заявки на закупку можно создать локальный
|
||||
проект и сразу привязать его к закупке:
|
||||
|
||||
- POST /api/v1/procurement/requests/<id>/create-project/ с JSON {"name": "Название"}
|
||||
- ответ 201 содержит ID и название созданного проекта;
|
||||
- проект создаётся атомарно с привязкой и записью в историю;
|
||||
- требуется активная авторизованная учётная запись и доступ к заявке;
|
||||
- ограничение — 50 проектов на заявку, а изменение существующих привязок
|
||||
остаётся защищено отдельным правом.
|
||||
|
||||
@ -537,6 +537,7 @@ export default function ProcurementPage() {
|
||||
departments,
|
||||
employees,
|
||||
projects,
|
||||
rememberProject,
|
||||
newProjectName,
|
||||
setNewProjectName,
|
||||
processingDepartments,
|
||||
@ -2119,6 +2120,7 @@ export default function ProcurementPage() {
|
||||
currentUserId={user?.id}
|
||||
request={selectedRequest}
|
||||
displayUserName={displayUserName}
|
||||
onProjectCreated={rememberProject}
|
||||
onProjectsSaved={async () => {
|
||||
await refreshOne(selectedRequest.id);
|
||||
await loadPage1();
|
||||
@ -2962,6 +2964,7 @@ export default function ProcurementPage() {
|
||||
currentUserId={user?.id}
|
||||
request={resolvedDetail}
|
||||
displayUserName={displayUserName}
|
||||
onProjectCreated={rememberProject}
|
||||
onProjectsSaved={async () => {
|
||||
await refreshOne(resolvedDetail.id);
|
||||
await loadPage1();
|
||||
@ -3223,6 +3226,7 @@ export default function ProcurementPage() {
|
||||
currentUserId={user?.id}
|
||||
request={selectedRequest}
|
||||
displayUserName={displayUserName}
|
||||
onProjectCreated={rememberProject}
|
||||
onProjectsSaved={async () => {
|
||||
await refreshOne(selectedRequest.id);
|
||||
await loadPage1();
|
||||
|
||||
@ -47,6 +47,7 @@ export default function ProjectsPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<LocalProject[]>([]),
|
||||
[canCreate, setCanCreate] = useState(false),
|
||||
[canImportExternal, setCanImportExternal] = useState(false),
|
||||
[create, setCreate] = useState(false),
|
||||
[date, setDate] = useState<string | null>(null),
|
||||
[help, setHelp] = useState(false),
|
||||
@ -95,6 +96,7 @@ export default function ProjectsPage() {
|
||||
.then(([o, c]) => {
|
||||
if (live) {
|
||||
setCanCreate(o.can_create);
|
||||
setCanImportExternal(o.can_import_external);
|
||||
setCalendar(c);
|
||||
setManageableProjects(o.manageable_projects);
|
||||
}
|
||||
@ -496,7 +498,7 @@ export default function ProjectsPage() {
|
||||
className="app-text-muted shrink-0 group-hover:text-[var(--accent-primary)]"
|
||||
/>
|
||||
</button>
|
||||
{canCreate && (
|
||||
{canImportExternal && (
|
||||
<div className="ml-auto flex shrink-0 gap-2">
|
||||
<button
|
||||
className={`${primaryClass} px-3 py-2 text-xs`}
|
||||
@ -759,7 +761,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{canCreate ? (
|
||||
{canImportExternal ? (
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-[var(--border-subtle)] pt-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FolderKanban, Loader2, Pencil } from "lucide-react";
|
||||
import { FolderKanban, Loader2, Pencil, Plus } from "lucide-react";
|
||||
|
||||
import { SearchableSelectMulti } from "@/components/shared/SearchableSelect";
|
||||
import { apiClient } from "@/lib/api";
|
||||
@ -12,11 +12,13 @@ type ProjectOption = { id: number; name: string };
|
||||
type Props = {
|
||||
request: ProcurementRequest;
|
||||
onSaved: () => Promise<void>;
|
||||
onCreated?: (project: ProjectOption) => void;
|
||||
};
|
||||
|
||||
export default function ProcurementProjectsQuickEditor({
|
||||
request,
|
||||
onSaved,
|
||||
onCreated,
|
||||
}: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@ -24,9 +26,13 @@ export default function ProcurementProjectsQuickEditor({
|
||||
const [options, setOptions] = useState<ProjectOption[]>([]);
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [createdMessage, setCreatedMessage] = useState("");
|
||||
|
||||
const openEditor = async () => {
|
||||
if (loading || busy) return;
|
||||
if (loading || busy || creating) return;
|
||||
setEditing(true);
|
||||
setSelected(request.projects || []);
|
||||
setError("");
|
||||
@ -48,7 +54,7 @@ export default function ProcurementProjectsQuickEditor({
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (busy || loading) return;
|
||||
if (busy || loading || creating) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@ -66,6 +72,39 @@ export default function ProcurementProjectsQuickEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const createProject = async () => {
|
||||
const name = projectName.trim();
|
||||
if (!name || creating || busy || loading) return;
|
||||
setCreating(true);
|
||||
setError("");
|
||||
setCreatedMessage("");
|
||||
try {
|
||||
const project = await apiClient.createProcurementRequestProject(
|
||||
request.id,
|
||||
name,
|
||||
);
|
||||
setOptions((current) =>
|
||||
[...current, project].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "ru", { sensitivity: "base" }),
|
||||
),
|
||||
);
|
||||
setSelected((current) => [...new Set([...current, project.id])]);
|
||||
onCreated?.(project);
|
||||
setProjectName("");
|
||||
setCreateOpen(false);
|
||||
setCreatedMessage("Проект создан и добавлен к заявке.");
|
||||
await onSaved();
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Не удалось создать и добавить проект.",
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentIds = [...(request.projects || [])].sort((a, b) => a - b);
|
||||
const nextIds = [...selected].sort((a, b) => a - b);
|
||||
const unchanged =
|
||||
@ -91,7 +130,7 @@ export default function ProcurementProjectsQuickEditor({
|
||||
void openEditor();
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
disabled={busy || creating}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-50"
|
||||
aria-expanded={editing}
|
||||
>
|
||||
@ -147,7 +186,9 @@ export default function ProcurementProjectsQuickEditor({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={busy || unchanged || selected.length > 50}
|
||||
disabled={
|
||||
busy || creating || unchanged || selected.length > 50
|
||||
}
|
||||
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
@ -169,6 +210,74 @@ export default function ProcurementProjectsQuickEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{!createOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateOpen(true);
|
||||
setError("");
|
||||
setCreatedMessage("");
|
||||
}}
|
||||
disabled={busy || loading || creating}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Создать проект
|
||||
</button>
|
||||
) : (
|
||||
<form
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createProject();
|
||||
}}
|
||||
>
|
||||
<label className="min-w-0 flex-1 space-y-1 text-xs">
|
||||
<span className="app-text-muted">Название нового проекта</span>
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
maxLength={255}
|
||||
value={projectName}
|
||||
onChange={(event) => setProjectName(event.target.value)}
|
||||
disabled={creating}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Например, Ремонт цеха"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !projectName.trim()}
|
||||
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : null}
|
||||
{creating ? "Создаём…" : "Создать и добавить"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={creating}
|
||||
onClick={() => {
|
||||
setCreateOpen(false);
|
||||
setProjectName("");
|
||||
setError("");
|
||||
}}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-xs"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{createdMessage ? (
|
||||
<p className="text-xs text-emerald-600" role="status">
|
||||
{createdMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-red-500" role="alert">
|
||||
{error}
|
||||
|
||||
@ -55,6 +55,7 @@ interface ProcurementRequestDetailContentProps {
|
||||
) => string;
|
||||
canProcessItems?: boolean;
|
||||
onProjectsSaved?: () => Promise<void>;
|
||||
onProjectCreated?: (project: { id: number; name: string }) => void;
|
||||
busyKey?: string | null;
|
||||
canDeleteAnyComment?: boolean;
|
||||
onUpdateItem?: (
|
||||
@ -1864,6 +1865,7 @@ export function ProcurementRequestDetailContent({
|
||||
displayUserName,
|
||||
canProcessItems = false,
|
||||
onProjectsSaved,
|
||||
onProjectCreated,
|
||||
busyKey,
|
||||
canDeleteAnyComment = false,
|
||||
onUpdateItem,
|
||||
@ -1999,6 +2001,7 @@ export function ProcurementRequestDetailContent({
|
||||
key={request.id}
|
||||
request={request}
|
||||
onSaved={onProjectsSaved}
|
||||
onCreated={onProjectCreated}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -143,7 +143,7 @@ export function Modal({
|
||||
full: "max-w-[98vw] sm:max-w-[95vw]",
|
||||
};
|
||||
|
||||
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh]`;
|
||||
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[calc(100dvh-1rem)] sm:max-h-[90dvh]`;
|
||||
|
||||
const showHeader = !noHeader && (title || showCloseButton);
|
||||
|
||||
@ -201,11 +201,13 @@ export function Modal({
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="shrink-0 px-4 pt-3 sm:px-6 sm:pt-4">{footer}</div>
|
||||
<div className="shrink-0 px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:pt-4 sm:pb-6">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom padding */}
|
||||
{!noPadding && <div className="pb-4 sm:pb-6" />}
|
||||
{!noPadding && !footer && <div className="pb-4 sm:pb-6" />}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@ -196,6 +196,15 @@ export function useProcurementPage(user: User | null) {
|
||||
const [canCreateProjects, setCanCreateProjects] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const rememberProject = useCallback((project: ProjectOption) => {
|
||||
setProjects((current) =>
|
||||
current.some((option) => option.id === project.id)
|
||||
? current
|
||||
: [...current, project].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "ru", { sensitivity: "base" }),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -1552,12 +1561,7 @@ export function useProcurementPage(user: User | null) {
|
||||
starts_on: null,
|
||||
ends_on: null,
|
||||
});
|
||||
setProjects((current) =>
|
||||
[...current, { id: created.id, name: created.name }].sort(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name, "ru", { sensitivity: "base" }),
|
||||
),
|
||||
);
|
||||
rememberProject({ id: created.id, name: created.name });
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
projects: [...new Set([...current.projects, created.id])],
|
||||
@ -1570,7 +1574,7 @@ export function useProcurementPage(user: User | null) {
|
||||
} finally {
|
||||
setCreatingProject(false);
|
||||
}
|
||||
}, [canCreateProjects, newProjectName, user?.id]);
|
||||
}, [canCreateProjects, newProjectName, rememberProject, user?.id]);
|
||||
|
||||
const handleUpdateItem = useCallback(
|
||||
async (
|
||||
@ -1888,6 +1892,7 @@ export function useProcurementPage(user: User | null) {
|
||||
processingDepartments,
|
||||
subscriptionProcessingDepartments,
|
||||
projects,
|
||||
rememberProject,
|
||||
canCreateProjects,
|
||||
newProjectName,
|
||||
setNewProjectName,
|
||||
|
||||
@ -43,6 +43,15 @@ export function createProcurementApi(
|
||||
id: number,
|
||||
): Promise<{ id: number; name: string }[]> =>
|
||||
request(`/api/v1/procurement/requests/${id}/project-options/`),
|
||||
createProcurementRequestProject: (
|
||||
id: number,
|
||||
name: string,
|
||||
): Promise<{ id: number; name: string }> =>
|
||||
request("/api/v1/procurement/requests/" + id + "/create-project/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
setProcurementRequestProjects: (id: number, projects: number[]) =>
|
||||
request(`/api/v1/procurement/requests/${id}/projects/`, {
|
||||
method: "PATCH",
|
||||
|
||||
@ -37,6 +37,7 @@ export function createProjectsApi(request: RequestFn) {
|
||||
request(`${root}${id}/`),
|
||||
getProjectOptions: (): Promise<{
|
||||
can_create: boolean;
|
||||
can_import_external: boolean;
|
||||
employees: { id: number; name: string }[];
|
||||
manageable_projects: ProjectOption[];
|
||||
}> => request(root + "options/"),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user