Improve procurement receipt handling and avatar crop

This commit is contained in:
lizerk 2026-06-11 17:19:44 +03:00
parent 76799c453e
commit 31b91fe15c
7 changed files with 557 additions and 23 deletions

View File

@ -1330,14 +1330,42 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
if error_response is not None:
return error_response
if item.effective_received_quantity <= 0:
current_received_quantity = item.effective_received_quantity
if current_received_quantity <= 0:
return Response(
{"detail": "По позиции нет подтверждённого получения"},
status=status.HTTP_400_BAD_REQUEST,
)
target_received_quantity = 0
if "cancel_quantity" in request.data:
raw_cancel_quantity = request.data.get("cancel_quantity")
try:
cancel_quantity = int(raw_cancel_quantity)
except (TypeError, ValueError):
return Response(
{"cancel_quantity": "Укажите целое число отменяемых единиц"},
status=status.HTTP_400_BAD_REQUEST,
)
if cancel_quantity < 1:
return Response(
{"cancel_quantity": "Количество должно быть больше 0"},
status=status.HTTP_400_BAD_REQUEST,
)
if cancel_quantity > current_received_quantity:
return Response(
{
"cancel_quantity": (
"Не может быть больше текущего полученного количества"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
target_received_quantity = current_received_quantity - cancel_quantity
effective_ordered_quantity = item.effective_ordered_quantity
item.received_quantity = 0
item.received_quantity = target_received_quantity
if item.ordered_quantity is None and effective_ordered_quantity > 0:
item.ordered_quantity = min(effective_ordered_quantity, item.quantity)
item.execution_status = self._workflow_status_from_quantities(item)
@ -1409,9 +1437,49 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
if error_response is not None:
return error_response
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
item.ordered_quantity = item.quantity
item.received_quantity = item.quantity
current_received_quantity = item.effective_received_quantity
target_received_quantity = item.quantity
if "received_quantity" in request.data:
raw_received_quantity = request.data.get("received_quantity")
try:
target_received_quantity = int(raw_received_quantity)
except (TypeError, ValueError):
return Response(
{"received_quantity": "Укажите целое число полученных единиц"},
status=status.HTTP_400_BAD_REQUEST,
)
if target_received_quantity < 1:
return Response(
{"received_quantity": "Количество должно быть больше 0"},
status=status.HTTP_400_BAD_REQUEST,
)
if target_received_quantity > item.quantity:
return Response(
{
"received_quantity": (
"Не может быть больше количества позиции"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
if target_received_quantity <= current_received_quantity:
return Response(
{
"received_quantity": (
"Должно быть больше текущего полученного количества"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
item.received_quantity = target_received_quantity
item.ordered_quantity = min(
item.quantity,
max(item.effective_ordered_quantity, target_received_quantity),
)
item.execution_status = self._workflow_status_from_quantities(item)
item.save(
update_fields=[
"execution_status",

View File

@ -2484,6 +2484,83 @@ class TestProcessingDepartmentWorkflow:
assert processing_request.status == ProcurementStatus.COMPLETED
assert processing_request.completed_at is not None
def test_requestor_can_confirm_partial_item_received(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=3,
ordered_quantity=3,
received_quantity=0,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-confirm-received',
kwargs={'pk': item.id},
)
response = api_client.post(
url,
{'received_quantity': 2},
format='json',
)
assert response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
assert item.ordered_quantity == 3
assert item.received_quantity == 2
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
)
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
def test_confirm_item_received_rejects_invalid_quantity(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=2,
ordered_quantity=2,
received_quantity=1,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-confirm-received',
kwargs={'pk': item.id},
)
too_low_response = api_client.post(
url,
{'received_quantity': 1},
format='json',
)
too_high_response = api_client.post(
url,
{'received_quantity': 3},
format='json',
)
assert too_low_response.status_code == status.HTTP_400_BAD_REQUEST
assert too_high_response.status_code == status.HTTP_400_BAD_REQUEST
item.refresh_from_db()
assert item.received_quantity == 1
def test_requestor_can_cancel_item_issue(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
@ -2556,6 +2633,82 @@ class TestProcessingDepartmentWorkflow:
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.completed_at is None
def test_requestor_can_cancel_partial_item_received(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=4,
ordered_quantity=4,
received_quantity=3,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-cancel-received',
kwargs={'pk': item.id},
)
response = api_client.post(
url,
{'cancel_quantity': 1},
format='json',
)
assert response.status_code == status.HTTP_200_OK
item.refresh_from_db()
processing_request.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.ORDERED
assert item.ordered_quantity == 4
assert item.received_quantity == 2
assert (
processing_request.fulfillment_status
== ProcurementFulfillmentStatus.PARTIALLY_RECEIVED
)
assert processing_request.status == ProcurementStatus.IN_PROGRESS
def test_cancel_item_received_rejects_invalid_quantity(
self, api_client, user, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=2,
ordered_quantity=2,
received_quantity=1,
execution_status=ProcurementItemExecutionStatus.ORDERED,
)
processing_request.executor = supply_user
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.save()
api_client.force_authenticate(user=user)
url = reverse(
'api:v1:procurement:procurementitem-cancel-received',
kwargs={'pk': item.id},
)
too_low_response = api_client.post(
url,
{'cancel_quantity': 0},
format='json',
)
too_high_response = api_client.post(
url,
{'cancel_quantity': 2},
format='json',
)
assert too_low_response.status_code == status.HTTP_400_BAD_REQUEST
assert too_high_response.status_code == status.HTTP_400_BAD_REQUEST
item.refresh_from_db()
assert item.received_quantity == 1
def test_cancel_received_uses_received_status_as_effective_quantity(
self, api_client, user, supply_user, processing_request,
procurement_item_factory

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import dynamic from "next/dynamic";
import {
ArrowDown,
ArrowUp,
@ -41,6 +42,10 @@ const themeIcons = {
Monitor,
} as const;
const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), {
ssr: false,
});
const mobileNavCards = [
{
value: "top",
@ -133,6 +138,7 @@ export default function SettingsPage() {
const { canInstall, install, isInstalled, isRegistrationReady } = usePwa();
const {
activeVerbCount,
avatarCropperImage,
avatarInputRef,
changingPassword,
contactsDirty,
@ -167,6 +173,8 @@ export default function SettingsPage() {
user,
verbTypes,
handleAvatarChange,
handleAvatarCropCancel,
handleAvatarCropComplete,
handleChangePassword,
handleDndToggle,
handleLogoutOthers,
@ -201,8 +209,9 @@ export default function SettingsPage() {
}
return (
<AppShell>
<div className="space-y-6">
<>
<AppShell>
<div className="space-y-6">
{/* <PageHeader
title="Настройки"
subtitle="Единая точка для темы, профиля, контактов и правил доставки уведомлений."
@ -1149,7 +1158,16 @@ export default function SettingsPage() {
</div>
</SectionCard>
</div>
</div>
</AppShell>
</div>
</AppShell>
{avatarCropperImage ? (
<AvatarCropper
initialImage={avatarCropperImage}
onCropComplete={handleAvatarCropComplete}
onCancel={handleAvatarCropCancel}
/>
) : null}
</>
);
}

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import { AlertTriangle, Check, CheckCircle2, CircleDot, ExternalLink, MessageSquare, Minus, Plus, RotateCcw, Save, SlidersHorizontal, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { Modal } from "@/components/ui";
import { cleanLinkRows, linkHref, toLinkRows } from "@/lib/procurementLinks";
import { getProcurementUnitLabel } from "@/lib/procurementUnits";
@ -25,8 +26,8 @@ interface ProcurementRequestDetailContentProps {
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number, receivedQuantity?: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number, cancelQuantity?: number) => void | Promise<unknown>;
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
itemCommentsMap?: Record<number, ProcurementItemComment[]>;
itemCommentDrafts?: Record<number, string>;
@ -159,8 +160,8 @@ interface ProcurementItemCardProps {
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onReportItemIssue?: (requestId: number, itemId: number, text?: string) => void | Promise<unknown>;
onCancelItemIssue?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number) => void | Promise<unknown>;
onConfirmItemReceived?: (requestId: number, itemId: number, receivedQuantity?: number) => void | Promise<unknown>;
onCancelItemReceived?: (requestId: number, itemId: number, cancelQuantity?: number) => void | Promise<unknown>;
comments?: ProcurementItemComment[];
commentDraft?: string;
commentsOpen?: boolean;
@ -197,12 +198,17 @@ function ProcurementItemCard({
}: ProcurementItemCardProps) {
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
const [processingOpen, setProcessingOpen] = useState(false);
const [receiptModalOpen, setReceiptModalOpen] = useState(false);
const [receiptQuantity, setReceiptQuantity] = useState("1");
const [cancelReceiptModalOpen, setCancelReceiptModalOpen] = useState(false);
const [cancelReceiptQuantity, setCancelReceiptQuantity] = useState("1");
const links = Array.isArray(item.links) ? item.links.filter(Boolean) : [];
const expectedDeliveryDates = toDateRows(item.expected_delivery_dates);
const commentsTotal = item.comments_count ?? comments.length;
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity);
const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity);
const remainingQuantity = Math.max(0, requestedQuantity - receivedQuantity);
const itemUnitLabel = getProcurementUnitLabel(item.unit);
const status = item.execution_status || "pending";
const statusLabel = item.execution_status_display || executionStatusOptions.find((option) => option.value === status)?.label || "Не выполнено";
@ -244,6 +250,79 @@ function ProcurementItemCard({
});
};
const adjustReceiptQuantity = (delta: number) => {
setReceiptQuantity((current) => {
const parsed = toOptionalInteger(current) ?? 1;
return String(Math.min(remainingQuantity, Math.max(1, parsed + delta)));
});
};
const adjustCancelReceiptQuantity = (delta: number) => {
setCancelReceiptQuantity((current) => {
const parsed = toOptionalInteger(current) ?? 1;
return String(Math.min(receivedQuantity, Math.max(1, parsed + delta)));
});
};
const openReceiptModal = () => {
setReceiptQuantity(String(remainingQuantity));
setReceiptModalOpen(true);
};
const openCancelReceiptModal = () => {
setCancelReceiptQuantity(String(receivedQuantity));
setCancelReceiptModalOpen(true);
};
const confirmReceipt = (quantityReceivedNow: number) => {
const normalizedQuantity = Math.min(remainingQuantity, Math.max(1, Math.trunc(quantityReceivedNow)));
const nextReceivedQuantity = receivedQuantity + normalizedQuantity;
return onConfirmItemReceived?.(requestId, item.id, nextReceivedQuantity);
};
const handleConfirmReceiptClick = () => {
if (remainingQuantity > 1) {
openReceiptModal();
return;
}
void confirmReceipt(remainingQuantity);
};
const cancelReceipt = (quantityToCancel: number) => {
const normalizedQuantity = Math.min(receivedQuantity, Math.max(1, Math.trunc(quantityToCancel)));
return onCancelItemReceived?.(requestId, item.id, normalizedQuantity);
};
const handleCancelReceiptClick = () => {
if (receivedQuantity > 1) {
openCancelReceiptModal();
return;
}
void cancelReceipt(receivedQuantity);
};
const submitReceiptModal = async () => {
const parsedQuantity = toOptionalInteger(receiptQuantity);
if (!parsedQuantity || parsedQuantity < 1 || parsedQuantity > remainingQuantity) return;
const result = await confirmReceipt(parsedQuantity);
if (result !== false) {
setReceiptModalOpen(false);
}
};
const submitCancelReceiptModal = async () => {
const parsedQuantity = toOptionalInteger(cancelReceiptQuantity);
if (!parsedQuantity || parsedQuantity < 1 || parsedQuantity > receivedQuantity) return;
const result = await cancelReceipt(parsedQuantity);
if (result !== false) {
setCancelReceiptModalOpen(false);
}
};
const saveItemProcessing = () => {
return onUpdateItem?.(requestId, item.id, {
execution_status: draft.execution_status,
@ -386,7 +465,7 @@ function ProcurementItemCard({
{canCancelReceived ? (
<button
type="button"
onClick={() => void onCancelItemReceived?.(requestId, item.id)}
onClick={handleCancelReceiptClick}
disabled={busyKey === `item-cancel-received-${item.id}`}
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
@ -398,7 +477,7 @@ function ProcurementItemCard({
{canConfirmReceived ? (
<button
type="button"
onClick={() => void onConfirmItemReceived?.(requestId, item.id)}
onClick={handleConfirmReceiptClick}
disabled={busyKey === `item-confirm-received-${item.id}`}
className="app-action-ghost inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium disabled:opacity-60"
>
@ -653,6 +732,158 @@ function ProcurementItemCard({
</div>
</div>
) : null}
{receiptModalOpen ? (
<Modal
isOpen
onClose={() => setReceiptModalOpen(false)}
title="Подтвердить получение"
size="sm"
footer={
<div className="flex gap-3">
<button
type="button"
onClick={() => setReceiptModalOpen(false)}
disabled={busyKey === `item-confirm-received-${item.id}`}
className="app-action-secondary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={() => void submitReceiptModal()}
disabled={
busyKey === `item-confirm-received-${item.id}` ||
!toOptionalInteger(receiptQuantity) ||
Number(receiptQuantity) < 1 ||
Number(receiptQuantity) > remainingQuantity
}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Подтвердить
</button>
</div>
}
>
<div className="space-y-4">
<div>
<p className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">{item.name}</p>
<p className="app-text-muted mt-1 text-sm">
Получено {receivedQuantity}/{requestedQuantity} {itemUnitLabel}. Осталось {remainingQuantity} {itemUnitLabel}.
</p>
</div>
<label className="block">
<span className="app-text-muted mb-2 block text-sm">Получено сейчас, {itemUnitLabel}</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustReceiptQuantity(-1)}
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
disabled={busyKey === `item-confirm-received-${item.id}`}
>
<Minus size={14} />
</button>
<input
type="number"
min={1}
max={remainingQuantity}
value={receiptQuantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
setReceiptQuantity(parsed === null ? "" : String(Math.min(remainingQuantity, parsed)));
}}
className="app-input min-w-0 flex-1 rounded-lg px-4 py-2.5 text-sm"
autoFocus
/>
<button
type="button"
onClick={() => adjustReceiptQuantity(1)}
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
disabled={busyKey === `item-confirm-received-${item.id}`}
>
<Plus size={14} />
</button>
</div>
</label>
</div>
</Modal>
) : null}
{cancelReceiptModalOpen ? (
<Modal
isOpen
onClose={() => setCancelReceiptModalOpen(false)}
title="Отменить получение"
size="sm"
footer={
<div className="flex gap-3">
<button
type="button"
onClick={() => setCancelReceiptModalOpen(false)}
disabled={busyKey === `item-cancel-received-${item.id}`}
className="app-action-secondary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={() => void submitCancelReceiptModal()}
disabled={
busyKey === `item-cancel-received-${item.id}` ||
!toOptionalInteger(cancelReceiptQuantity) ||
Number(cancelReceiptQuantity) < 1 ||
Number(cancelReceiptQuantity) > receivedQuantity
}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Отменить получение
</button>
</div>
}
>
<div className="space-y-4">
<div>
<p className="app-text-wrap text-sm font-semibold text-[var(--foreground)]">{item.name}</p>
<p className="app-text-muted mt-1 text-sm">
Сейчас получено {receivedQuantity}/{requestedQuantity} {itemUnitLabel}.
</p>
</div>
<label className="block">
<span className="app-text-muted mb-2 block text-sm">Отменить сейчас, {itemUnitLabel}</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustCancelReceiptQuantity(-1)}
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
disabled={busyKey === `item-cancel-received-${item.id}`}
>
<Minus size={14} />
</button>
<input
type="number"
min={1}
max={receivedQuantity}
value={cancelReceiptQuantity}
onChange={(event) => {
const parsed = toOptionalInteger(event.target.value);
setCancelReceiptQuantity(parsed === null ? "" : String(Math.min(receivedQuantity, parsed)));
}}
className="app-input min-w-0 flex-1 rounded-lg px-4 py-2.5 text-sm"
autoFocus
/>
<button
type="button"
onClick={() => adjustCancelReceiptQuantity(1)}
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
disabled={busyKey === `item-cancel-received-${item.id}`}
>
<Plus size={14} />
</button>
</div>
</label>
</div>
</Modal>
) : null}
</div>
);
}

View File

@ -1001,11 +1001,11 @@ export function useProcurementPage(user: User | null) {
}
}, [refreshAfterMutation]);
const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number) => {
const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number, receivedQuantity?: number) => {
try {
setBusyKey(`item-confirm-received-${itemId}`);
setActionError(null);
await apiClient.confirmProcurementItemReceived(itemId);
await apiClient.confirmProcurementItemReceived(itemId, receivedQuantity);
setActionSuccess("Получение позиции подтверждено.");
await refreshAfterMutation(requestId);
return true;
@ -1017,11 +1017,11 @@ export function useProcurementPage(user: User | null) {
}
}, [refreshAfterMutation]);
const handleCancelItemReceived = useCallback(async (requestId: number, itemId: number) => {
const handleCancelItemReceived = useCallback(async (requestId: number, itemId: number, cancelQuantity?: number) => {
try {
setBusyKey(`item-cancel-received-${itemId}`);
setActionError(null);
await apiClient.cancelProcurementItemReceived(itemId);
await apiClient.cancelProcurementItemReceived(itemId, cancelQuantity);
setActionSuccess("Получение позиции отменено.");
await refreshAfterMutation(requestId);
return true;

View File

@ -80,6 +80,28 @@ const preferencesSignature = (value: NotificationPreferences | null) => {
const isApiNotFoundError = (error: unknown) =>
error instanceof Error && error.message.includes("API Error: 404");
function buildCroppedAvatarFileName(fileName: string) {
const baseName = fileName.trim().replace(/\.[^.]+$/, "") || "avatar";
return `${baseName}.jpg`;
}
function dataUrlToFile(dataUrl: string, fileName: string) {
const [header, base64Data] = dataUrl.split(",");
if (!base64Data) {
throw new Error("Invalid image data URL");
}
const mime = header.match(/^data:(.*?);base64$/)?.[1] || "image/jpeg";
const binary = window.atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], fileName, { type: mime });
}
export function settingsInitials(firstName?: string, lastName?: string) {
return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П";
}
@ -138,6 +160,8 @@ export function useSettingsPage() {
});
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarCropperImage, setAvatarCropperImage] = useState<string | null>(null);
const [avatarCropperFileName, setAvatarCropperFileName] = useState("avatar.jpg");
const [savingProfile, setSavingProfile] = useState(false);
const [savingContacts, setSavingContacts] = useState(false);
@ -356,6 +380,10 @@ export function useSettingsPage() {
const handleAvatarChange = (file: File | null) => {
if (!file) return;
if (avatarInputRef.current) {
avatarInputRef.current.value = "";
}
if (!file.type.startsWith("image/")) {
toast.error("Можно загрузить только изображение");
return;
@ -365,14 +393,37 @@ export function useSettingsPage() {
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onload = () => {
setAvatarPreview(typeof reader.result === "string" ? reader.result : null);
if (typeof reader.result !== "string") {
toast.error("Не удалось прочитать изображение");
return;
}
setAvatarCropperFileName(buildCroppedAvatarFileName(file.name));
setAvatarCropperImage(reader.result);
};
reader.readAsDataURL(file);
};
const handleAvatarCropComplete = (croppedImage: string) => {
try {
const croppedFile = dataUrlToFile(croppedImage, avatarCropperFileName);
setAvatarFile(croppedFile);
setAvatarPreview(croppedImage);
setAvatarCropperImage(null);
setAvatarCropperFileName("avatar.jpg");
} catch (error) {
console.error(error);
toast.error("Не удалось обработать обрезанное фото");
}
};
const handleAvatarCropCancel = () => {
setAvatarCropperImage(null);
setAvatarCropperFileName("avatar.jpg");
};
const handlePushToggle = async (enabled: boolean) => {
setPreferences((current) => ({ ...current, push_enabled: enabled }));
@ -617,8 +668,11 @@ export function useSettingsPage() {
unreadVerbCount,
user,
verbTypes,
avatarCropperImage,
avatarPreview,
handleAvatarChange,
handleAvatarCropCancel,
handleAvatarCropComplete,
handleChangePassword,
handleDndToggle,
handleLogoutOthers,

View File

@ -34,8 +34,18 @@ export function createProcurementApi(request: RequestFn) {
reportProcurementItemIssue: (id: number, text?: string) =>
request(`/api/v1/procurement/items/${id}/report_issue/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text || '' }) }),
cancelProcurementItemIssue: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_issue/`, { method: 'POST' }),
confirmProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/confirm_received/`, { method: 'POST' }),
cancelProcurementItemReceived: (id: number) => request(`/api/v1/procurement/items/${id}/cancel_received/`, { method: 'POST' }),
confirmProcurementItemReceived: (id: number, receivedQuantity?: number) =>
request(`/api/v1/procurement/items/${id}/confirm_received/`, {
method: 'POST',
headers: receivedQuantity === undefined ? undefined : { 'Content-Type': 'application/json' },
body: receivedQuantity === undefined ? undefined : JSON.stringify({ received_quantity: receivedQuantity }),
}),
cancelProcurementItemReceived: (id: number, cancelQuantity?: number) =>
request(`/api/v1/procurement/items/${id}/cancel_received/`, {
method: 'POST',
headers: cancelQuantity === undefined ? undefined : { 'Content-Type': 'application/json' },
body: cancelQuantity === undefined ? undefined : JSON.stringify({ cancel_quantity: cancelQuantity }),
}),
deleteProcurementItem: (id: number): Promise<void> => request(`/api/v1/procurement/items/${id}/`, { method: 'DELETE' }),
getProcurementItemComments: (itemId: number) => request(`/api/v1/procurement/items/${itemId}/comments/`),
addProcurementItemComment: (itemId: number, text: string) =>