Improve procurement receipt handling and avatar crop
This commit is contained in:
parent
76799c453e
commit
31b91fe15c
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user