first commit

This commit is contained in:
Bivekich
2025-06-26 06:59:59 +03:00
commit d44874775c
450 changed files with 76635 additions and 0 deletions

View File

@ -0,0 +1,91 @@
import React from "react";
const AddressDetails = ({ onClose, onBack, address, setAddress }) => (
<div className="flex flex-col px-8 pt-8 bg-white rounded-2xl w-[480px] max-md:w-full max-md:px-5 max-md:pb-8">
<div className="flex relative flex-col gap-8 items-start h-[730px] w-[420px] max-md:w-full max-md:h-auto max-sm:gap-5">
<div className="flex relative flex-col gap-5 items-start self-stretch max-sm:gap-4">
<div className="flex relative gap-2.5 justify-center items-center self-stretch pr-10 max-md:pr-5">
<div className="text-3xl font-bold leading-9 flex-[1_0_0] text-gray-950 max-md:text-2xl max-sm:text-2xl">
Пункт выдачи
</div>
<div onClick={onClose} className="cursor-pointer absolute right-0 top-1">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M1.8 18L0 16.2L7.2 9L0 1.8L1.8 0L9 7.2L16.2 0L18 1.8L10.8 9L18 16.2L16.2 18L9 10.8L1.8 18Z" fill="#000814"/>
</svg>
</div>
</div>
<div className="flex relative gap-2.5 items-center self-stretch max-md:flex-wrap">
<input
type="text"
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Адрес"
className="gap-2.5 self-stretch px-6 py-4 mt-3.5 w-full text-lg leading-snug whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[55px] text-neutral-500 max-md:px-5 outline-none"
/>
</div>
</div>
<div className="relative gap-2 self-stretch text-base font-bold leading-5 text-gray-950">
Калининград, Улица Космонавта Леонова 12
</div>
<div className="flex relative flex-col gap-2.5 items-start">
<div className="flex relative gap-1.5 items-center">
<div className="relative aspect-[1/1] h-[18px] w-[18px]" />
<div className="text-sm leading-5 text-gray-600 max-sm:text-sm">
Доставка для юридических лиц
</div>
</div>
<div className="flex relative gap-1.5 items-center">
<div className="relative aspect-[1/1] h-[18px] w-[18px]" />
<div className="text-sm leading-5 text-gray-600 max-sm:text-sm">
Возврат товаров
</div>
</div>
<div className="flex relative gap-1.5 items-center">
<div className="relative aspect-[1/1] h-[18px] w-[18px]" />
<div className="text-sm leading-5 text-gray-600 max-sm:text-sm">
Срок хранения заказа - 15 дней
</div>
</div>
</div>
<div className="flex relative flex-col gap-2 items-start self-stretch">
<div className="self-stretch text-lg font-bold leading-5 text-gray-950 max-sm:text-base">
Режим работы
</div>
<div className="flex relative gap-2 items-start self-stretch max-sm:flex-col max-sm:gap-1">
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Понедельник-пятница
</div>
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
<span>09:00 - 18:00</span>
<br />
<span>13:00 - 14:00 (перерыв)</span>
</div>
</div>
<div className="flex relative gap-2 items-start self-stretch max-sm:flex-col max-sm:gap-1">
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Суббота
</div>
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
09:00 - 14:00
</div>
</div>
<div className="flex relative gap-2 items-start self-stretch max-sm:flex-col max-sm:gap-1">
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Воскресенье
</div>
<div className="text-sm leading-5 text-gray-400 flex-[1_0_0] max-sm:text-sm">
Выходной
</div>
</div>
</div>
</div>
<div
className="cursor-pointer relative gap-2.5 self-stretch px-5 py-3.5 text-base leading-5 text-center text-white bg-red-600 rounded-xl h-[50px] max-sm:px-4 max-sm:py-3 max-sm:h-12 max-sm:text-base"
onClick={onBack}
>
Добавить адрес доставки
</div>
</div>
);
export default AddressDetails;

View File

@ -0,0 +1,219 @@
import React, { useState } from "react";
import CustomCheckbox from './CustomCheckbox';
const Tabs = ({ deliveryType, setDeliveryType }) => (
<div className="flex items-center mt-5 w-full text-lg font-medium text-center whitespace-nowrap rounded-xl bg-slate-200">
<div
className={`flex flex-1 shrink gap-5 items-center self-stretch my-auto rounded-xl basis-0 cursor-pointer ${deliveryType === "pickup" ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
onClick={() => setDeliveryType("pickup")}
>
<div className="flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 max-md:px-5">
Самовывоз
</div>
</div>
<div
className={`flex flex-1 shrink gap-5 items-center self-stretch my-auto rounded-xl basis-0 cursor-pointer ${deliveryType === "courier" ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
onClick={() => setDeliveryType("courier")}
>
<div className="flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 max-md:px-5">
Курьером
</div>
</div>
</div>
);
const AddressForm = ({ onDetectLocation, address, setAddress, onBack }) => {
const [deliveryType, setDeliveryType] = useState("pickup"); // "pickup" или "courier"
const [isPrivateHouse, setIsPrivateHouse] = useState(false);
const [placeName, setPlaceName] = useState("");
const [city, setCity] = useState("");
const [houseNumber, setHouseNumber] = useState("");
const [apartment, setApartment] = useState("");
const [entrance, setEntrance] = useState("");
const [floor, setFloor] = useState("");
const [intercom, setIntercom] = useState("");
const [courierComment, setCourierComment] = useState("");
const [recipientName, setRecipientName] = useState("");
const [recipientPhone, setRecipientPhone] = useState("");
return (
<div className="flex flex-col px-8 pt-8 bg-white rounded-2xl w-[480px] max-md:w-full max-md:px-5 max-md:pb-8">
<div className="flex flex-col w-full leading-tight">
<div className="text-3xl font-bold text-gray-950">
Способ доставки
</div>
<Tabs deliveryType={deliveryType} setDeliveryType={setDeliveryType} />
</div>
{deliveryType === "pickup" && (
<div className="flex flex-col mt-10 w-full">
<div className="flex flex-col w-full">
<div className="text-lg font-bold leading-tight text-gray-950">
Куда доставить заказ?
</div>
<div className="mt-2 text-sm leading-snug text-gray-400 pb-2">
Выберите пункт выдачи на карте или используйте поиск
</div>
</div>
<input
type="text"
value={address}
onChange={e => setAddress(e.target.value)}
placeholder="Адрес"
className="gap-2.5 self-stretch px-6 py-4 mt-3.5 w-full text-lg leading-snug whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[55px] text-neutral-500 max-md:px-5 outline-none"
/>
<div
className="cursor-pointer flex gap-1.5 items-center mt-3.5 w-full text-sm leading-snug text-gray-600"
onClick={onDetectLocation}
>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/09d97ef790819abac069b7cd0595eae50a6e5b63?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-square w-[18px]"
/>
<div className="self-stretch my-auto">
Определить местоположение
</div>
</div>
</div>
)}
{deliveryType === "courier" && (
<>
<div className="flex relative flex-col gap-5 items-start self-stretch max-sm:gap-4 mt-10">
<div className="flex relative gap-2.5 items-center self-stretch max-sm:gap-2">
<input
type="text"
value={placeName}
onChange={e => setPlaceName(e.target.value)}
placeholder="Название, например 'Дом'"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex relative flex-col gap-2.5 items-start self-stretch max-sm:gap-2">
<div
layer-name="Адрес доставки"
className="relative self-stretch text-lg font-bold leading-5 text-gray-950 max-sm:text-base"
>
Адрес доставки
</div>
<div className="flex relative gap-2.5 items-start self-stretch max-sm:gap-2">
<input
type="text"
value={city}
onChange={e => setCity(e.target.value)}
placeholder="Город"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
<input
type="text"
value={houseNumber}
onChange={e => setHouseNumber(e.target.value)}
placeholder="Номер дома"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex relative gap-2.5 items-center self-stretch max-sm:gap-2">
<input
type="text"
value={apartment}
onChange={e => setApartment(e.target.value)}
placeholder="Квартира"
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
<div
layer-name="Check_block"
className="flex relative gap-2.5 items-center flex-[1_0_0] h-[22px] max-sm:gap-2"
>
<CustomCheckbox selected={isPrivateHouse} onSelect={() => setIsPrivateHouse(v => !v)} />
<div
layer-name="Экспресс доставка"
className="relative text-sm font-medium leading-5 text-zinc-900"
>
Частный дом
</div>
</div>
</div>
<div className="flex relative gap-2.5 items-start self-stretch max-sm:gap-2">
<div className="flex flex-col flex-[1_0_0]">
<div className="text-xs text-gray-400 mb-1">Подъезд</div>
<input
type="text"
value={entrance}
onChange={e => setEntrance(e.target.value)}
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 w-full h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex flex-col flex-[1_0_0]">
<div className="text-xs text-gray-400 mb-1">Этаж</div>
<input
type="text"
value={floor}
onChange={e => setFloor(e.target.value)}
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 w-full h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div className="flex flex-col flex-[1_0_0]">
<div className="text-xs text-gray-400 mb-1">Домофон</div>
<input
type="text"
value={intercom}
onChange={e => setIntercom(e.target.value)}
layer-name="Input"
className="relative gap-2.5 px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 w-full h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
</div>
<div className="flex relative gap-2.5 items-start self-stretch h-[100px] max-sm:gap-2 max-sm:h-20">
<textarea
value={courierComment}
onChange={e => setCourierComment(e.target.value)}
placeholder="Комментарий курьеру"
layer-name="Input"
className="relative gap-2.5 self-stretch px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 flex-[1_0_0] text-neutral-500 max-sm:gap-2 max-sm:text-base outline-none"
style={{ resize: 'none' }}
/>
</div>
</div>
<div className="flex relative flex-col gap-2.5 items-start self-stretch max-sm:gap-2">
<div
layer-name="Данные получателя"
className="relative self-stretch text-lg font-bold leading-5 text-gray-950 max-sm:text-base"
>
Данные получателя
</div>
<input
type="text"
value={recipientName}
onChange={e => setRecipientName(e.target.value)}
placeholder="ФИО"
layer-name="Input"
className="relative gap-2.5 self-stretch px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
<input
type="text"
value={recipientPhone}
onChange={e => setRecipientPhone(e.target.value)}
placeholder="Номер телефона"
layer-name="Input"
className="relative gap-2.5 self-stretch px-6 py-4 text-lg leading-6 bg-white rounded border border-solid border-stone-300 h-[55px] text-neutral-500 max-sm:gap-2 max-sm:text-base max-sm:h-[50px] outline-none"
/>
</div>
<div
className="cursor-pointer relative gap-2.5 self-stretch px-5 py-3.5 text-base leading-5 text-center text-white bg-red-600 rounded-xl h-[50px] max-sm:px-4 max-sm:py-3 max-sm:h-12 max-sm:text-base"
onClick={onBack}
>
Добавить адрес доставки
</div>
</div>
</>
)}
</div>
);
};
export default AddressForm;

View File

@ -0,0 +1,622 @@
import React, { useState, useRef, useEffect } from "react";
import { useMutation, useLazyQuery } from '@apollo/client';
import CustomCheckbox from './CustomCheckbox';
import PickupPointSelector from '../delivery/PickupPointSelector';
import { YandexPickupPoint } from '@/lib/graphql/yandex-delivery';
import { CREATE_CLIENT_DELIVERY_ADDRESS, UPDATE_CLIENT_DELIVERY_ADDRESS, GET_CLIENT_DELIVERY_ADDRESSES, GET_ADDRESS_SUGGESTIONS } from '@/lib/graphql';
interface AddressFormWithPickupProps {
onDetectLocation: () => void;
address: string;
setAddress: (address: string) => void;
onBack: () => void;
onCityChange: (cityName: string) => void;
onPickupPointSelect: (point: YandexPickupPoint) => void;
selectedPickupPoint?: YandexPickupPoint;
editingAddress?: any; // Для редактирования существующего адреса
}
// Компонент автокомплита адресов
interface AddressAutocompleteProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
}
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value, onChange, placeholder }) => {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [getAddressSuggestions] = useLazyQuery(GET_ADDRESS_SUGGESTIONS, {
onCompleted: (data) => {
console.log('Автокомплит: получены данные', data);
if (data.addressSuggestions) {
console.log('Автокомплит: установка предложений', data.addressSuggestions);
setSuggestions(data.addressSuggestions);
setShowSuggestions(true);
}
setIsLoading(false);
},
onError: (error) => {
console.error('Ошибка автокомплита:', error);
setIsLoading(false);
}
});
useEffect(() => {
console.log('Автокомплит: значение изменилось', value);
if (!value || value.length < 3) {
console.log('Автокомплит: значение слишком короткое, очистка');
setSuggestions([]);
setShowSuggestions(false);
return;
}
const delayedSearch = setTimeout(() => {
console.log('Автокомплит: запуск поиска для', value);
setIsLoading(true);
getAddressSuggestions({
variables: { query: value }
});
}, 300);
return () => clearTimeout(delayedSearch);
}, [value, getAddressSuggestions]);
const handleSuggestionClick = (suggestion: string) => {
onChange(suggestion);
setShowSuggestions(false);
};
return (
<div className="relative">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full bg-transparent outline-none text-gray-600"
onFocus={() => {
if (suggestions.length > 0) setShowSuggestions(true);
}}
onBlur={() => setShowSuggestions(false)}
/>
{isLoading && (
<div className="absolute right-4 top-1/2 transform -translate-y-1/2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-600"></div>
</div>
)}
{/* Отладочная информация */}
{/* {process.env.NODE_ENV === 'development' && (
<div className="absolute right-16 top-1/2 transform -translate-y-1/2 text-xs text-gray-400">
{suggestions.length > 0 ? `${suggestions.length} подсказок` : 'Нет подсказок'}
</div>
)} */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{suggestions.map((suggestion, index) => (
<div
key={index}
className="px-4 py-3 hover:bg-gray-100 cursor-pointer text-sm border-b border-gray-100 last:border-b-0"
onMouseDown={() => {
handleSuggestionClick(suggestion);
}}
>
{suggestion}
</div>
))}
</div>
)}
</div>
);
};
const Tabs = ({ deliveryType, setDeliveryType }: { deliveryType: string; setDeliveryType: (type: string) => void; }) => (
<div className="flex items-center w-full text-base font-medium text-center whitespace-nowrap rounded-xl bg-slate-100 mb-6">
<button
type="button"
style={deliveryType === 'COURIER' ? { color: '#fff' } : {}}
className={`flex-1 py-3 rounded-xl transition-colors duration-150 ${deliveryType === 'COURIER' ? 'bg-red-600 text-white shadow' : 'text-gray-700 hover:text-red-600'}`}
onClick={() => setDeliveryType('COURIER')}
>
Курьером
</button>
<button
type="button"
style={deliveryType === 'PICKUP' ? { color: '#fff' } : {}}
className={`flex-1 py-3 rounded-xl transition-colors duration-150 ${deliveryType === 'PICKUP' ? 'bg-red-600 text-white shadow' : 'text-gray-700 hover:text-red-600'}`}
onClick={() => setDeliveryType('PICKUP')}
>
Самовывоз
</button>
</div>
);
// Компонент фильтра по типу ПВЗ
const PickupTypeFilter = ({ selectedType, onTypeChange }: {
selectedType: string;
onTypeChange: (type: string) => void;
}) => (
<div className="flex flex-col gap-3">
<label className="text-sm font-medium text-gray-700">Тип пункта выдачи *</label>
<div className="flex gap-2">
<div
onClick={() => onTypeChange('pickup_point')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md border transition-colors text-center cursor-pointer select-none ${
selectedType === 'pickup_point'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-red-300'
}`}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onTypeChange('pickup_point'); }}
>
ПВЗ
</div>
<div
onClick={() => onTypeChange('terminal')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md border transition-colors text-center cursor-pointer select-none ${
selectedType === 'terminal'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-red-300'
}`}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onTypeChange('terminal'); }}
>
Постомат
</div>
</div>
</div>
);
// Компонент детальной информации о ПВЗ
const PickupPointDetails = ({ point, onConfirm, onCancel }: {
point: YandexPickupPoint;
onConfirm: () => void;
onCancel: () => void;
}) => (
<div className="flex flex-col gap-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex justify-between items-start">
<h3 className="text-lg font-semibold text-gray-900">Подтверждение выбора ПВЗ</h3>
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="space-y-3">
<div>
<h4 className="font-medium text-gray-900">{point.name}</h4>
<p className="text-sm text-gray-600">{point.address.fullAddress}</p>
<span className="inline-block mt-1 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
{point.typeLabel}
</span>
</div>
<div>
<h5 className="font-medium text-gray-900 mb-2">Режим работы:</h5>
<div className="text-sm text-gray-600 whitespace-pre-line">
{point.formattedSchedule}
</div>
</div>
{point.contact.phone && (
<div>
<h5 className="font-medium text-gray-900">Телефон:</h5>
<p className="text-sm text-gray-600">{point.contact.phone}</p>
</div>
)}
{point.instruction && (
<div>
<h5 className="font-medium text-gray-900">Дополнительная информация:</h5>
<p className="text-sm text-gray-600">{point.instruction}</p>
</div>
)}
<div className="flex gap-2 pt-2">
<div className="flex items-center gap-2">
{point.paymentMethods.includes('card_on_receipt') && (
<span className="flex items-center gap-1 text-xs text-green-600">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>
Оплата картой
</span>
)}
{point.isYandexBranded && (
<span className="flex items-center gap-1 text-xs text-blue-600">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>
Яндекс ПВЗ
</span>
)}
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<div
onClick={onCancel}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 text-center cursor-pointer select-none"
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onCancel(); }}
>
Изменить выбор
</div>
<div
onClick={onConfirm}
style={{ color: '#fff' }}
className="flex-1 px-4 py-2 text-sm font-medium bg-red-600 rounded-md hover:bg-red-700 !text-[#fff] text-center cursor-pointer select-none"
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onConfirm(); }}
>
Подтвердить выбор
</div>
</div>
</div>
);
const AddressFormWithPickup = ({
onDetectLocation,
address,
setAddress,
onBack,
onCityChange,
onPickupPointSelect,
selectedPickupPoint,
editingAddress
}: AddressFormWithPickupProps) => {
const [deliveryType, setDeliveryType] = useState(editingAddress?.deliveryType || 'COURIER');
const [pickupTypeFilter, setPickupTypeFilter] = useState<string>('pickup_point');
const [showPickupDetails, setShowPickupDetails] = useState(false);
const [formData, setFormData] = useState({
name: editingAddress?.name || '',
address: editingAddress?.address || '',
entrance: editingAddress?.entrance || '',
floor: editingAddress?.floor || '',
apartment: editingAddress?.apartment || '',
intercom: editingAddress?.intercom || '',
deliveryTime: editingAddress?.deliveryTime || '',
contactPhone: editingAddress?.contactPhone || '',
comment: editingAddress?.comment || ''
});
const [createAddress] = useMutation(CREATE_CLIENT_DELIVERY_ADDRESS, {
onCompleted: () => {
alert('Адрес доставки сохранен!');
onBack();
},
onError: (error) => {
console.error('Ошибка сохранения адреса:', error);
alert('Ошибка сохранения адреса: ' + error.message);
},
refetchQueries: [{ query: GET_CLIENT_DELIVERY_ADDRESSES }]
});
const [updateAddress] = useMutation(UPDATE_CLIENT_DELIVERY_ADDRESS, {
onCompleted: () => {
alert('Адрес доставки обновлен!');
onBack();
},
onError: (error) => {
console.error('Ошибка обновления адреса:', error);
alert('Ошибка обновления адреса: ' + error.message);
},
refetchQueries: [{ query: GET_CLIENT_DELIVERY_ADDRESSES }]
});
const handlePickupPointSelect = (point: YandexPickupPoint) => {
// Проверяем соответствие выбранному типу
if (point.type !== pickupTypeFilter) {
alert(`Выбранный пункт не соответствует типу "${pickupTypeFilter === 'pickup_point' ? 'ПВЗ' : 'Постомат'}". Пожалуйста, выберите другой пункт.`);
return;
}
onPickupPointSelect(point);
setShowPickupDetails(true);
};
const handleSave = async () => {
if (deliveryType === 'COURIER') {
if (!formData.name || !formData.address) {
alert('Пожалуйста, заполните обязательные поля: название и адрес');
return;
}
const addressInput = {
name: formData.name,
address: formData.address,
deliveryType: 'COURIER',
comment: formData.comment,
entrance: formData.entrance || null,
floor: formData.floor || null,
apartment: formData.apartment || null,
intercom: formData.intercom || null,
deliveryTime: formData.deliveryTime || null,
contactPhone: formData.contactPhone || null
};
try {
if (editingAddress) {
// Обновляем существующий адрес
await updateAddress({
variables: {
id: editingAddress.id,
input: addressInput
}
});
} else {
// Создаем новый адрес
await createAddress({
variables: {
input: addressInput
}
});
}
} catch (error) {
console.error('Ошибка сохранения:', error);
}
} else if (deliveryType === 'PICKUP' && selectedPickupPoint) {
// Для самовывоза показываем детали перед сохранением
if (!showPickupDetails) {
setShowPickupDetails(true);
return;
}
const pickupInput = {
name: selectedPickupPoint.name,
address: selectedPickupPoint.address.fullAddress,
deliveryType: 'PICKUP',
comment: formData.comment || null,
entrance: null,
floor: null,
apartment: null,
intercom: null,
deliveryTime: null,
contactPhone: null
};
try {
if (editingAddress) {
// Обновляем существующий адрес
await updateAddress({
variables: {
id: editingAddress.id,
input: pickupInput
}
});
} else {
// Создаем новый адрес
await createAddress({
variables: {
input: pickupInput
}
});
}
} catch (error) {
console.error('Ошибка сохранения ПВЗ:', error);
}
} else {
alert('Пожалуйста, выберите пункт выдачи соответствующего типа');
}
};
const timeSlots = [
'9:00 - 12:00',
'12:00 - 15:00',
'15:00 - 18:00',
'18:00 - 21:00',
'Любое время'
];
// Желаемое время доставки — кастомный селект
const [isTimeOpen, setIsTimeOpen] = useState(false);
return (
<div className="flex flex-col px-8 pt-8 bg-white rounded-2xl w-[480px] max-md:w-full max-md:px-4 max-md:pb-8 ">
<div className="flex flex-col w-full leading-tight mb-2">
<div className="text-2xl font-bold text-gray-950 mb-2">
{editingAddress ? 'Редактировать адрес' : 'Адрес доставки'}
</div>
<Tabs deliveryType={deliveryType} setDeliveryType={setDeliveryType} />
</div>
{deliveryType === 'COURIER' ? (
<div className="flex flex-col gap-4 w-full">
{/* Название адреса */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Название адреса *</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Например: Дом, Офис, Дача"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
{/* Адрес с автокомплитом */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Адрес доставки *</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<AddressAutocomplete
value={formData.address}
onChange={(value) => setFormData(prev => ({ ...prev, address: value }))}
placeholder="Введите адрес"
/>
</div>
</div>
{/* Дополнительные поля */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Подъезд</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.entrance}
onChange={(e) => setFormData(prev => ({ ...prev, entrance: e.target.value }))}
placeholder="1"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Этаж</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.floor}
onChange={(e) => setFormData(prev => ({ ...prev, floor: e.target.value }))}
placeholder="5"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Квартира/офис</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.apartment}
onChange={(e) => setFormData(prev => ({ ...prev, apartment: e.target.value }))}
placeholder="25"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Домофон</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
value={formData.intercom}
onChange={(e) => setFormData(prev => ({ ...prev, intercom: e.target.value }))}
placeholder="25К"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
</div>
{/* Время доставки */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Желаемое время доставки</label>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsTimeOpen((prev) => !prev)}
tabIndex={0}
onBlur={() => setIsTimeOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{formData.deliveryTime || 'Выберите время'}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isTimeOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{timeSlots.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === formData.deliveryTime ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setFormData(prev => ({ ...prev, deliveryTime: option })); setIsTimeOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
{/* Контактный телефон */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Контактный телефон</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="tel"
value={formData.contactPhone}
onChange={(e) => setFormData(prev => ({ ...prev, contactPhone: e.target.value }))}
placeholder="+7 (999) 123-45-67"
className="w-full bg-transparent outline-none text-gray-600"
/>
</div>
</div>
{/* Комментарий для курьера */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Комментарий для курьера</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<textarea
value={formData.comment}
onChange={(e) => setFormData(prev => ({ ...prev, comment: e.target.value }))}
placeholder="Дополнительная информация для курьера"
rows={3}
className="w-full bg-transparent outline-none text-gray-600 resize-none"
/>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-4 w-full">
<PickupTypeFilter
selectedType={pickupTypeFilter}
onTypeChange={setPickupTypeFilter}
/>
{showPickupDetails && selectedPickupPoint ? (
<PickupPointDetails
point={selectedPickupPoint}
onConfirm={() => {
setShowPickupDetails(false);
handleSave();
}}
onCancel={() => setShowPickupDetails(false)}
/>
) : (
<PickupPointSelector
selectedPoint={selectedPickupPoint}
onPointSelect={handlePickupPointSelect}
onCityChange={onCityChange}
placeholder={`Выберите ${pickupTypeFilter === 'pickup_point' ? 'ПВЗ' : 'постомат'}`}
typeFilter={pickupTypeFilter}
/>
)}
{/* Комментарий для самовывоза */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Комментарий</label>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<textarea
value={formData.comment}
onChange={(e) => setFormData(prev => ({ ...prev, comment: e.target.value }))}
placeholder="Дополнительная информация"
rows={3}
className="w-full bg-transparent outline-none text-gray-600 resize-none"
/>
</div>
</div>
</div>
)}
<div
onClick={handleSave}
style={{ color: '#fff' }}
className="w-full mt-6 mb-6 px-5 py-3.5 text-base font-medium bg-red-600 rounded-xl hover:bg-red-700 transition-colors shadow-md disabled:opacity-60 disabled:cursor-not-allowed !text-[#fff] text-center cursor-pointer select-none"
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') handleSave(); }}
>
{deliveryType === 'PICKUP' && selectedPickupPoint && !showPickupDetails
? 'Показать детали и сохранить'
: editingAddress ? 'Сохранить изменения' : 'Сохранить адрес доставки'
}
</div>
</div>
);
};
export default AddressFormWithPickup;

View File

@ -0,0 +1,22 @@
import React from "react";
interface CustomCheckboxProps {
selected: boolean;
onSelect: () => void;
}
const CustomCheckbox: React.FC<CustomCheckboxProps> = ({ selected, onSelect }) => (
<div
className={"div-block-7" + (selected ? " active" : "")}
onClick={onSelect}
style={{ width: 24, height: 24, borderRadius: 6, border: '1px solid #D1D5DB', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: selected ? '#EF4444' : '#fff', transition: 'background 0.2s' }}
>
{selected && (
<svg width="14" height="10" viewBox="0 0 14 10" fill="none">
<path d="M2 5.5L6 9L12 2" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
);
export default CustomCheckbox;

View File

@ -0,0 +1,499 @@
import React from "react";
import { useMutation } from '@apollo/client';
import { CREATE_CLIENT_LEGAL_ENTITY, UPDATE_CLIENT_LEGAL_ENTITY } from '@/lib/graphql';
interface LegalEntityFormBlockProps {
inn: string;
setInn: (v: string) => void;
form: string;
setForm: (v: string) => void;
isFormOpen: boolean;
setIsFormOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
formOptions: string[];
ogrn: string;
setOgrn: (v: string) => void;
kpp: string;
setKpp: (v: string) => void;
jurAddress: string;
setJurAddress: (v: string) => void;
shortName: string;
setShortName: (v: string) => void;
fullName: string;
setFullName: (v: string) => void;
factAddress: string;
setFactAddress: (v: string) => void;
taxSystem: string;
setTaxSystem: (v: string) => void;
isTaxSystemOpen: boolean;
setIsTaxSystemOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
taxSystemOptions: string[];
nds: string;
setNds: (v: string) => void;
isNdsOpen: boolean;
setIsNdsOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
ndsOptions: string[];
ndsPercent: string;
setNdsPercent: (v: string) => void;
accountant: string;
setAccountant: (v: string) => void;
responsible: string;
setResponsible: (v: string) => void;
responsiblePosition: string;
setResponsiblePosition: (v: string) => void;
responsiblePhone: string;
setResponsiblePhone: (v: string) => void;
signatory: string;
setSignatory: (v: string) => void;
editingEntity?: {
id: string;
shortName: string;
fullName?: string;
form?: string;
legalAddress?: string;
actualAddress?: string;
taxSystem?: string;
responsiblePhone?: string;
responsiblePosition?: string;
responsibleName?: string;
accountant?: string;
signatory?: string;
registrationReasonCode?: string;
ogrn?: string;
inn: string;
vatPercent: number;
} | null;
onAdd: () => void;
onCancel: () => void;
}
const LegalEntityFormBlock: React.FC<LegalEntityFormBlockProps> = ({
inn,
setInn,
form,
setForm,
isFormOpen,
setIsFormOpen,
formOptions,
ogrn,
setOgrn,
kpp,
setKpp,
jurAddress,
setJurAddress,
shortName,
setShortName,
fullName,
setFullName,
factAddress,
setFactAddress,
taxSystem,
setTaxSystem,
isTaxSystemOpen,
setIsTaxSystemOpen,
taxSystemOptions,
nds,
setNds,
isNdsOpen,
setIsNdsOpen,
ndsOptions,
ndsPercent,
setNdsPercent,
accountant,
setAccountant,
responsible,
setResponsible,
responsiblePosition,
setResponsiblePosition,
responsiblePhone,
setResponsiblePhone,
signatory,
setSignatory,
editingEntity,
onAdd,
onCancel,
}) => {
const [createLegalEntity, { loading: createLoading }] = useMutation(CREATE_CLIENT_LEGAL_ENTITY, {
onCompleted: () => {
console.log('Юридическое лицо создано');
onAdd();
},
onError: (error) => {
console.error('Ошибка создания юридического лица:', error);
alert('Ошибка создания юридического лица: ' + error.message);
}
});
const [updateLegalEntity, { loading: updateLoading }] = useMutation(UPDATE_CLIENT_LEGAL_ENTITY, {
onCompleted: () => {
console.log('Юридическое лицо обновлено');
onAdd();
},
onError: (error) => {
console.error('Ошибка обновления юридического лица:', error);
alert('Ошибка обновления юридического лица: ' + error.message);
}
});
const loading = createLoading || updateLoading;
const handleSave = async () => {
// Валидация
if (!inn || inn.length < 10) {
alert('Введите корректный ИНН');
return;
}
if (!shortName.trim()) {
alert('Введите краткое наименование');
return;
}
if (!jurAddress.trim()) {
alert('Введите юридический адрес');
return;
}
if (form === 'Выбрать') {
alert('Выберите форму организации');
return;
}
if (taxSystem === 'Выбрать') {
alert('Выберите систему налогообложения');
return;
}
try {
// Преобразуем НДС в число
let vatPercent = 20; // по умолчанию
if (nds === 'Без НДС') {
vatPercent = 0;
} else if (nds === 'НДС 10%') {
vatPercent = 10;
} else if (nds === 'НДС 20%') {
vatPercent = 20;
} else if (ndsPercent) {
vatPercent = parseFloat(ndsPercent) || 20;
}
if (editingEntity) {
// Обновляем существующее юридическое лицо
await updateLegalEntity({
variables: {
id: editingEntity.id,
input: {
inn: inn.trim(),
shortName: shortName.trim(),
fullName: fullName.trim() || shortName.trim(),
form: form,
legalAddress: jurAddress.trim(),
actualAddress: factAddress.trim() || null,
taxSystem: taxSystem,
vatPercent: vatPercent,
accountant: accountant.trim() || null,
responsibleName: responsible.trim() || null,
responsiblePosition: responsiblePosition.trim() || null,
responsiblePhone: responsiblePhone.trim() || null,
signatory: signatory.trim() || null,
ogrn: ogrn.trim() || null,
registrationReasonCode: kpp.trim() || null
}
}
});
} else {
// Создаем новое юридическое лицо
await createLegalEntity({
variables: {
input: {
inn: inn.trim(),
shortName: shortName.trim(),
fullName: fullName.trim() || shortName.trim(),
form: form,
legalAddress: jurAddress.trim(),
actualAddress: factAddress.trim() || null,
taxSystem: taxSystem,
vatPercent: vatPercent,
accountant: accountant.trim() || null,
responsibleName: responsible.trim() || null,
responsiblePosition: responsiblePosition.trim() || null,
responsiblePhone: responsiblePhone.trim() || null,
signatory: signatory.trim() || null,
ogrn: ogrn.trim() || null,
registrationReasonCode: kpp.trim() || null
}
}
});
}
} catch (error) {
console.error('Ошибка сохранения:', error);
}
};
return (
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
{editingEntity ? 'Редактирование юридического лица' : 'Данные юридического лица'}
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-start w-full whitespace-nowrap max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">ИНН</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="ИНН"
className="w-full bg-transparent outline-none text-gray-600"
value={inn}
onChange={e => setInn(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Форма</div>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsFormOpen((prev: boolean) => !prev)}
tabIndex={0}
onBlur={() => setIsFormOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{form}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isFormOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{formOptions.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === form ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setForm(option); setIsFormOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">ОГРН</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="ОГРН"
className="w-full bg-transparent outline-none text-neutral-500"
value={ogrn}
onChange={e => setOgrn(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">КПП</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="КПП"
className="w-full bg-transparent outline-none text-neutral-500"
value={kpp}
onChange={e => setKpp(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Юридический адрес</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Юридический адрес"
className="w-full bg-transparent outline-none text-neutral-500"
value={jurAddress}
onChange={e => setJurAddress(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Краткое наименование</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Краткое наименование"
className="w-full bg-transparent outline-none text-neutral-500"
value={shortName}
onChange={e => setShortName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Полное наименование</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Полное наименование"
className="w-full bg-transparent outline-none text-neutral-500"
value={fullName}
onChange={e => setFullName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Фактический адрес</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Фактический адрес"
className="w-full bg-transparent outline-none text-neutral-500"
value={factAddress}
onChange={e => setFactAddress(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Система налогоблажения</div>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsTaxSystemOpen((prev: boolean) => !prev)}
tabIndex={0}
onBlur={() => setIsTaxSystemOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{taxSystem}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isTaxSystemOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{taxSystemOptions.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === taxSystem ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setTaxSystem(option); setIsTaxSystemOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">НДС</div>
<div className="relative mt-1.5">
<div
className="flex gap-10 justify-between items-center px-6 py-3.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5 cursor-pointer select-none"
onClick={() => setIsNdsOpen((prev: boolean) => !prev)}
tabIndex={0}
onBlur={() => setIsNdsOpen(false)}
>
<span className="self-stretch my-auto text-neutral-500">{nds}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isNdsOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{ndsOptions.map(option => (
<li
key={option}
className={`px-6 py-3.5 cursor-pointer hover:bg-blue-100 ${option === nds ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setNds(option); setIsNdsOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">НДС %</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="НДС %"
className="w-full bg-transparent outline-none text-neutral-500"
value={ndsPercent}
onChange={e => setNdsPercent(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Бухгалтер</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Бухгалтер"
className="w-full bg-transparent outline-none text-neutral-500"
value={accountant}
onChange={e => setAccountant(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full max-md:max-w-full">
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Ответственный</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Ответственный"
className="w-full bg-transparent outline-none text-neutral-500"
value={responsible}
onChange={e => setResponsible(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Должность ответственного</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Должность ответственного"
className="w-full bg-transparent outline-none text-neutral-500"
value={responsiblePosition}
onChange={e => setResponsiblePosition(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Телефон ответственного</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full whitespace-nowrap bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Телефон ответственного"
className="w-full bg-transparent outline-none text-neutral-500"
value={responsiblePhone}
onChange={e => setResponsiblePhone(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Подписант</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="text"
placeholder="Подписант"
className="w-full bg-transparent outline-none text-neutral-500"
value={signatory}
onChange={e => setSignatory(e.target.value)}
/>
</div>
</div>
</div>
</div>
<div className="flex gap-8 items-start self-start mt-8 text-base font-medium leading-tight text-center whitespace-nowrap">
<div
className={`gap-2.5 self-stretch px-5 py-4 rounded-xl min-h-[50px] cursor-pointer text-white ${loading ? 'bg-gray-400' : 'bg-red-600 hover:bg-red-700'}`}
onClick={loading ? undefined : handleSave}
>
{loading ? 'Сохранение...' : (editingEntity ? 'Сохранить изменения' : 'Добавить')}
</div>
<div className="gap-2.5 self-stretch px-5 py-4 rounded-xl border border-red-600 min-h-[50px] cursor-pointer bg-white text-gray-950 hover:bg-gray-50" onClick={onCancel}>
Отменить
</div>
</div>
</div>
);
};
export default LegalEntityFormBlock;

View File

@ -0,0 +1,188 @@
import React from "react";
import Image from "next/image";
import { useRouter } from 'next/router';
import { useMutation } from '@apollo/client';
import { DELETE_CLIENT_LEGAL_ENTITY } from '@/lib/graphql';
interface LegalEntity {
id: string;
shortName: string;
fullName?: string;
form?: string;
legalAddress?: string;
actualAddress?: string;
taxSystem?: string;
responsiblePhone?: string;
responsiblePosition?: string;
responsibleName?: string;
accountant?: string;
signatory?: string;
registrationReasonCode?: string;
ogrn?: string;
inn: string;
vatPercent: number;
bankDetails: Array<{
id: string;
name: string;
accountNumber: string;
bankName: string;
bik: string;
correspondentAccount: string;
}>;
}
interface LegalEntityListBlockProps {
legalEntities: LegalEntity[];
onRefetch: () => void;
onEdit?: (entity: LegalEntity) => void;
}
const LegalEntityListBlock: React.FC<LegalEntityListBlockProps> = ({ legalEntities, onRefetch, onEdit }) => {
const router = useRouter();
const [deleteLegalEntity] = useMutation(DELETE_CLIENT_LEGAL_ENTITY, {
onCompleted: () => {
console.log('Юридическое лицо удалено');
onRefetch();
},
onError: (error) => {
console.error('Ошибка удаления юридического лица:', error);
alert('Ошибка удаления юридического лица');
}
});
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Вы уверены, что хотите удалить юридическое лицо "${name}"?`)) {
try {
await deleteLegalEntity({
variables: { id }
});
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
if (legalEntities.length === 0) {
return (
<div className="flex relative flex-col mt-5 gap-8 items-start self-stretch p-8 pl-8 bg-white rounded-2xl max-md:gap-5 max-md:p-5 max-sm:gap-4 max-sm:p-4">
<div className="text-3xl font-bold leading-8 text-gray-950 max-md:text-2xl max-sm:text-xl">
Юридические лица
</div>
<div className="text-gray-600">
У вас пока нет добавленных юридических лиц. Нажмите кнопку "Добавить юридическое лицо" для создания первого.
</div>
</div>
);
}
return (
<div
layer-name="Frame 2087324698"
className="flex relative flex-col mt-5 gap-8 items-start self-stretch p-8 pl-8 bg-white rounded-2xl max-md:gap-5 max-md:p-5 max-sm:gap-4 max-sm:p-4"
>
<div
layer-name="Юридические лица"
className="text-3xl font-bold leading-8 text-gray-950 max-md:text-2xl max-sm:text-xl"
>
Юридические лица
</div>
<div className="flex relative flex-col gap-2.5 items-start self-stretch">
{legalEntities.map((entity, idx) => (
<div
key={entity.id}
layer-name="legal"
className="flex relative flex-col gap-8 items-start self-stretch px-5 py-3 rounded-lg bg-slate-50 max-sm:px-4 max-sm:py-2.5"
>
<div className="flex relative justify-between items-center self-stretch max-sm:flex-col max-sm:gap-4 max-sm:items-start">
<div className="flex relative gap-5 items-center max-md:flex-wrap max-md:gap-4 max-sm:flex-col max-sm:gap-2.5 max-sm:items-start">
<div
layer-name={entity.shortName}
className="text-xl font-bold leading-5 text-gray-950 max-md:text-lg max-sm:text-base"
>
{entity.shortName}
</div>
<div
layer-name={`ИНН ${entity.inn}`}
className="text-sm leading-5 text-gray-600"
>
ИНН {entity.inn}
</div>
<div
layer-name="link_control_element"
className="flex relative gap-1.5 items-center cursor-pointer hover:text-red-600"
role="button"
tabIndex={0}
onClick={() => router.push('/profile-requisites')}
>
<div
layer-name="icon-wallet"
className="relative aspect-[1/1] h-[18px] w-[18px]"
>
<div>
<div
dangerouslySetInnerHTML={{
__html:
"<svg id=\"I48:1881;1705:18944;1705:18492;1149:3355\" width=\"16\" height=\"15\" viewBox=\"0 0 16 15\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" class=\"wallet-icon\" style=\"width: 16px; height: 14px; flex-shrink: 0; fill: #424F60; position: absolute; left: 1px; top: 2px\"> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.77778 3.16211C1.77778 3.04608 1.8246 2.9348 1.90795 2.85275C1.9913 2.7707 2.10435 2.72461 2.22222 2.72461H11.5556C11.7913 2.72461 12.0174 2.63242 12.1841 2.46833C12.3508 2.30423 12.4444 2.08167 12.4444 1.84961C12.4444 1.61754 12.3508 1.39499 12.1841 1.23089C12.0174 1.0668 11.7913 0.974609 11.5556 0.974609H2.22222C1.63285 0.974609 1.06762 1.20508 0.650874 1.61531C0.234126 2.02555 0 2.58195 0 3.16211V13.2246C0 13.6887 0.187301 14.1339 0.520699 14.462C0.854097 14.7902 1.30628 14.9746 1.77778 14.9746H14.2222C14.6937 14.9746 15.1459 14.7902 15.4793 14.462C15.8127 14.1339 16 13.6887 16 13.2246V5.34961C16 4.88548 15.8127 4.44036 15.4793 4.11217C15.1459 3.78398 14.6937 3.59961 14.2222 3.59961H2.22222C2.10435 3.59961 1.9913 3.55352 1.90795 3.47147C1.8246 3.38942 1.77778 3.27814 1.77778 3.16211ZM11.1111 10.5996C11.4647 10.5996 11.8039 10.4613 12.0539 10.2152C12.304 9.96905 12.4444 9.63521 12.4444 9.28711C12.4444 8.93901 12.304 8.60517 12.0539 8.35903C11.8039 8.11289 11.4647 7.97461 11.1111 7.97461C10.7575 7.97461 10.4184 8.11289 10.1683 8.35903C9.91825 8.60517 9.77778 8.93901 9.77778 9.28711C9.77778 9.63521 9.91825 9.96905 10.1683 10.2152C10.4184 10.4613 10.7575 10.5996 11.1111 10.5996Z\" fill=\"#424F60\"></path> </svg>",
}}
/>
</div>
</div>
<div
layer-name="Редактировать"
className="text-sm leading-5 text-gray-600"
>
Реквизиты компании
</div>
</div>
</div>
<div className="flex relative gap-5 items-center pr-2.5 max-md:gap-4 max-sm:flex-wrap max-sm:gap-2.5">
<div
role="button"
tabIndex={0}
className="flex relative gap-1.5 items-center cursor-pointer hover:text-red-600"
onClick={() => onEdit && onEdit(entity)}
>
<div className="relative h-4 w-[18px]">
<Image
src="/images/edit.svg"
alt="Редактировать"
width={16}
height={16}
className="absolute left-0.5 top-0"
/>
</div>
<div className="text-sm leading-5 text-gray-600">
Редактировать
</div>
</div>
<div
role="button"
tabIndex={0}
className="flex relative gap-1.5 items-center cursor-pointer hover:text-red-600"
onClick={() => handleDelete(entity.id, entity.shortName)}
>
<div className="relative h-4 w-[18px]">
<Image
src="/images/delete.svg"
alt="Удалить"
width={16}
height={16}
className="absolute left-0.5 top-0"
/>
</div>
<div className="text-sm leading-5 text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default LegalEntityListBlock;

View File

@ -0,0 +1,194 @@
import * as React from "react";
import LKMenu from '@/components/LKMenu';
import CustomCheckbox from './CustomCheckbox';
const NotificationMane = () => {
const [all, setAll] = React.useState(false);
const [delivery, setDelivery] = React.useState(false);
const [payment, setPayment] = React.useState(false);
const [reserve, setReserve] = React.useState(false);
const [refuse, setRefuse] = React.useState(false);
const [returnItem, setReturnItem] = React.useState(false);
const [upd, setUpd] = React.useState(false);
const [email, setEmail] = React.useState("");
const [division, setDivision] = React.useState("Все");
const divisionOptions = ["Все", "Склад 1", "Склад 2", "Офис"];
const [isDivisionOpen, setIsDivisionOpen] = React.useState(false);
const [address, setAddress] = React.useState("Все");
const addressOptions = ["Все", "Калининград, ул. Понартская, 5", "Москва, ул. Ленина, 10"];
const [isAddressOpen, setIsAddressOpen] = React.useState(false);
const [showAddEmail, setShowAddEmail] = React.useState(true);
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col p-8 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="flex flex-col w-full max-md:max-w-full">
<div className="flex flex-wrap gap-10 justify-between items-center w-full whitespace-nowrap max-md:max-w-full">
<div className="self-stretch my-auto text-xl font-bold leading-none text-gray-950">
voronin.p.e@gmail.com
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto text-sm leading-snug text-gray-600">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">Удалить</div>
</div>
</div>
<div className="flex flex-wrap gap-5 items-start mt-5 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">
Подразделение
</div>
<div className="relative mt-1.5">
<div
className="flex items-center justify-between px-6 py-4 w-full bg-white rounded border border-solid border-stone-300 text-neutral-500 max-md:px-5 max-md:max-w-full cursor-pointer select-none"
onClick={() => setIsDivisionOpen((prev) => !prev)}
tabIndex={0}
onBlur={() => setIsDivisionOpen(false)}
style={{ minHeight: 48 }}
>
<span className="text-neutral-500">{division}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isDivisionOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{divisionOptions.map(option => (
<li
key={option}
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 ${option === division ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setDivision(option); setIsDivisionOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">
Адрес доставки
</div>
<div className="relative mt-1.5">
<div
className="flex items-center justify-between px-6 py-4 w-full bg-white rounded border border-solid border-stone-300 text-neutral-500 max-md:px-5 max-md:max-w-full cursor-pointer select-none"
onClick={() => setIsAddressOpen((prev) => !prev)}
tabIndex={0}
onBlur={() => setIsAddressOpen(false)}
style={{ minHeight: 48 }}
>
<span className="text-neutral-500">{address}</span>
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
{isAddressOpen && (
<ul className="absolute left-0 right-0 z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg animate-fadeIn">
{addressOptions.map(option => (
<li
key={option}
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 ${option === address ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setAddress(option); setIsAddressOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-8 justify-between items-start mt-5 w-full max-md:max-w-full">
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={all} onSelect={() => setAll(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Все оповещения
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={delivery} onSelect={() => setDelivery(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Доставка товара
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={payment} onSelect={() => setPayment(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Поступление оплаты
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={reserve} onSelect={() => setReserve(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Снято с резерва
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={refuse} onSelect={() => setRefuse(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Отказ в поставке
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={returnItem} onSelect={() => setReturnItem(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
Возврат товара
</div>
</div>
<div className="flex gap-2.5 items-center pr-5">
<CustomCheckbox selected={upd} onSelect={() => setUpd(v => !v)} />
<div className="self-stretch my-auto text-sm font-medium leading-snug text-zinc-900">
УПД или чек
</div>
</div>
</div>
</div>
<div className="mt-8 w-full border border-solid bg-stone-300 border-stone-300 min-h-[1px] max-md:max-w-full" />
{showAddEmail && (
<div className="flex flex-col mt-8 w-full max-md:max-w-full">
<div className="text-xl font-bold leading-none text-gray-950">
Добавление e-mail для уведомлений
</div>
<div className="flex flex-col mt-5 w-full max-md:max-w-full">
<div className="text-sm leading-snug text-gray-950 max-md:max-w-full">
Адрес электронной почты
</div>
<div className="flex flex-wrap gap-5 items-start mt-1.5 w-full text-base font-medium leading-tight whitespace-nowrap max-md:max-w-full">
<div className="flex-1 shrink gap-2.5 self-stretch px-6 py-4 text-sm leading-snug bg-white rounded border border-solid basis-0 border-stone-300 min-h-[52px] min-w-[240px] text-neutral-500 max-md:px-5 max-md:max-w-full">
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Введите e-mail"
className="w-full bg-transparent outline-none text-gray-950 placeholder:text-gray-400"
style={{ border: 'none', padding: 0, margin: 0 }}
/>
</div>
<div className="cursor-pointer gap-2.5 self-stretch px-5 py-4 text-center text-white bg-red-600 rounded-xl min-h-[50px]" onClick={() => setShowAddEmail(false)}>
Готово
</div>
<div className="cursor-pointer gap-2.5 self-stretch px-5 py-4 text-center rounded-xl border border-red-600 border-solid min-h-[50px] text-gray-950" onClick={() => setShowAddEmail(false)}>
Отменить
</div>
</div>
</div>
</div>
)}
<div className="mt-8 w-full border border-solid bg-stone-300 border-stone-300 min-h-[1px] max-md:max-w-full" />
<div className="flex flex-wrap gap-10 justify-between items-start mt-8 w-full text-base font-medium leading-tight text-center max-md:max-w-full">
<div className="gap-2.5 self-stretch px-5 py-4 text-white whitespace-nowrap bg-red-600 rounded-xl min-h-[50px]">
Сохранить
</div>
<div
className={`cursor-pointer gap-2.5 self-stretch px-5 py-4 rounded-xl border border-red-600 border-solid min-h-[50px] min-w-[240px] text-gray-950${showAddEmail ? ' opacity-50 pointer-events-none' : ''}`}
onClick={() => { if (!showAddEmail) setShowAddEmail(true); }}
>
Добавить почту для уведомлений
</div>
</div>
</div>
</div>
);
};
export default NotificationMane;

View File

@ -0,0 +1,168 @@
import * as React from "react";
const selectArrow = (
<svg width="14" height="9" viewBox="0 0 14 9" fill="none" style={{ width: 12, height: 6 }}>
<path d="M1 1L7 7L13 1" stroke="#747474" strokeWidth="2" />
</svg>
);
type CustomSelectProps = {
value: string;
onChange: (value: string) => void;
options: string[];
placeholder?: string;
className?: string;
};
const CustomSelect: React.FC<CustomSelectProps> = ({ value, onChange, options, placeholder = "Выбрать", className = "" }) => {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={ref} className={`relative w-full ${className}`}>
<div
className="flex justify-between items-center px-6 py-4 bg-white rounded border border-solid border-stone-300 cursor-pointer"
onClick={() => setOpen((o) => !o)}
>
<div className={`text-sm leading-5 ${!value || value === placeholder ? "text-neutral-500" : "text-gray-950"}`}>
{value || placeholder}
</div>
{selectArrow}
</div>
{open && (
<div className="absolute left-0 right-0 mt-1 bg-white border border-stone-300 rounded shadow z-10">
{options.map((opt: string) => (
<div
key={opt}
className={`px-6 py-2 text-sm cursor-pointer hover:bg-slate-100 ${opt === value ? "font-medium text-gray-950" : "text-neutral-500"}`}
onClick={() => {
onChange(opt);
setOpen(false);
}}
>
{opt}
</div>
))}
</div>
)}
</div>
);
};
const periodOptions = ["Этот год", "Последний квартал", "Предыдущий год", "Другое"];
const buyerOptions = ["Покупатель 1", "Покупатель 2", "Покупатель 3"];
const sellerOptions = ["ООО 'ПротекАвто'", "Продавец 2", "Продавец 3"];
const ProfileActsMain = () => {
const [period, setPeriod] = React.useState("");
const [buyer, setBuyer] = React.useState("");
const [seller, setSeller] = React.useState(sellerOptions[0]);
const [email, setEmail] = React.useState("");
const tabOptions = ["Этот год", "Последний квартал", "Предыдущий год"];
const [activeTab, setActiveTab] = React.useState(tabOptions[0]);
return (
<>
<div className=" flex relative flex-col gap-8 items-start p-8 mx-auto my-0 w-full bg-white rounded-2xl max-md:gap-5 max-md:p-5 max-sm:gap-4 max-sm:p-4">
<div className="flex relative flex-col gap-8 items-start self-stretch max-md:gap-5 max-sm:gap-4">
<div className="flex relative flex-wrap gap-5 items-start self-stretch max-md:flex-col max-md:gap-4 max-sm:gap-2.5">
{tabOptions.map((tab) => (
<div
key={tab}
layer-name="Tabs_button"
className={`flex relative gap-5 items-center self-stretch rounded-xl flex-[1_0_0] min-w-[200px] max-md:gap-4 max-md:w-full max-md:min-w-[unset] max-sm:gap-2.5 ${activeTab === tab ? "" : "bg-slate-200"}`}
onClick={() => setActiveTab(tab)}
style={{ cursor: 'pointer' }}
>
<div className={`flex relative gap-5 justify-center items-center px-6 py-3.5 rounded-xl flex-[1_0_0] ${activeTab === tab ? "bg-red-600" : "bg-slate-200"}`}>
<div
layer-name="Курьером"
className={`relative text-lg font-medium leading-5 text-center max-sm:text-base ${activeTab === tab ? "text-white" : "text-gray-950"}`}
>
{tab}
</div>
</div>
</div>
))}
<CustomSelect
value={period}
onChange={setPeriod}
options={periodOptions}
placeholder="Выбрать период"
className="flex-[1_0_0] min-w-[200px] max-md:w-full max-md:min-w-[unset]"
/>
</div>
</div>
<div className="flex relative flex-wrap gap-5 items-start self-stretch max-md:flex-col max-md:gap-4 max-sm:gap-2.5">
<div className="flex relative flex-col gap-1.5 items-start flex-[1_0_0] min-w-[250px] max-md:w-full max-md:min-w-[unset]">
<div
layer-name="Покупатель"
className="relative self-stretch text-sm leading-5 text-gray-950"
>
Покупатель
</div>
<CustomSelect
value={buyer}
onChange={setBuyer}
options={buyerOptions}
placeholder="Выберите"
/>
</div>
<div className="flex relative flex-col gap-1.5 items-start flex-[1_0_0] min-w-[250px] max-md:w-full max-md:min-w-[unset]">
<div
layer-name="Продавец"
className="relative self-stretch text-sm leading-5 text-gray-950"
>
Продавец
</div>
<CustomSelect
value={seller}
onChange={setSeller}
options={sellerOptions}
placeholder="Выберите"
/>
</div>
<div className="flex relative flex-col gap-1.5 items-start flex-[1_0_0] min-w-[250px] max-md:w-full max-md:min-w-[unset]">
<div
layer-name="E-mail для получения акта сверки"
className="relative self-stretch text-sm leading-5 text-gray-950"
>
E-mail для получения акта сверки
</div>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="@"
className="flex relative gap-2.5 items-center self-stretch px-6 py-4 bg-white rounded border border-solid border-stone-300 h-[52px] max-sm:h-12 text-sm text-gray-950 placeholder-neutral-500 outline-none"
layer-name="Input"
/>
</div>
</div>
<div
layer-name="Button Small"
className="flex relative gap-2.5 justify-center items-center px-5 py-3.5 bg-red-600 rounded-xl cursor-pointer border-[none] h-[50px] max-sm:h-[46px]"
>
<div
layer-name="Button Small"
className="relative text-base font-medium leading-5 text-center text-white"
>
Получить акт сверки
</div>
</div>
</div>
</>
);
}
export default ProfileActsMain;

View File

@ -0,0 +1,98 @@
import React from "react";
type ProfileAddressCardProps = {
type: string;
title: string;
address: string;
storagePeriod?: string;
workTime?: string;
comment?: string;
onEdit?: () => void;
onDelete?: () => void;
onSelectMain?: () => void;
isMain?: boolean;
};
const ProfileAddressCard: React.FC<ProfileAddressCardProps> = ({
type,
title,
address,
storagePeriod,
workTime,
comment,
onEdit,
onDelete,
onSelectMain,
isMain
}) => (
<div className="flex flex-col justify-between items-start self-stretch p-8 bg-white rounded-lg border border-solid border-stone-300 sm:min-w-[340px] min-w-[200px] max-w-[404px] flex-[1_0_0] max-md:max-w-[350px] max-sm:p-5 max-sm:max-w-full">
<div className="flex flex-col gap-1.5 items-start self-stretch pb-8">
<div className="relative text-base leading-6 text-gray-950">{type}</div>
<div className="relative self-stretch text-xl font-bold leading-7 text-gray-950">{title}</div>
<div className="relative self-stretch text-base leading-6 text-gray-950">{address}</div>
</div>
<div className="flex flex-col gap-5 items-start self-stretch">
{storagePeriod && workTime && (
<div className="flex gap-5 items-start self-stretch max-sm:flex-col max-sm:gap-4">
<div className="flex flex-col gap-2 items-start flex-[1_0_0] min-w-[132px] max-sm:min-w-full">
<div className="overflow-hidden relative self-stretch text-sm leading-5 text-gray-600 text-ellipsis">Срок хранения</div>
<div className="overflow-hidden relative self-stretch text-lg font-medium leading-5 text-ellipsis text-gray-950">{storagePeriod}</div>
</div>
<div className="flex flex-col gap-2 items-start flex-[1_0_0] min-w-[132px] max-sm:min-w-full">
<div className="overflow-hidden relative self-stretch text-sm leading-5 text-gray-600 text-ellipsis">Ежедневно</div>
<div className="overflow-hidden relative text-lg font-medium leading-5 text-ellipsis text-gray-950">{workTime}</div>
</div>
</div>
)}
{comment && (
<div className="flex flex-col gap-2 items-start self-stretch min-w-[160px]">
<div className="relative self-stretch text-sm leading-5 text-gray-600">Комментарий</div>
<div className="relative self-stretch text-base leading-5 text-gray-950 break-words">
{comment}
</div>
</div>
)}
<div className="flex justify-between items-start self-stretch">
<div className="flex gap-1.5 items-center cursor-pointer group" onClick={onEdit}>
<img src="/images/edit.svg" alt="edit" width={18} height={18} className="mr-1.5 group-hover:filter-red" />
<div className="relative text-sm leading-5 text-gray-600">Редактировать</div>
</div>
<div className="flex gap-1.5 items-center cursor-pointer group" onClick={onDelete}>
<img src="/images/delete.svg" alt="delete" width={18} height={18} className="mr-1.5 group-hover:filter-red" />
<div className="relative text-sm leading-5 text-gray-600">Удалить</div>
</div>
</div>
{onSelectMain && (
<div className="flex gap-1.5 items-center cursor-pointer mt-4" onClick={onSelectMain}>
<div className="relative flex items-center justify-center aspect-[1/1] h-[18px] w-[18px]">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" style={{ width: 18, height: 18, flexShrink: 0 }}>
<circle cx="9" cy="9" r="8.5" stroke="#EC1C24" />
</svg>
{isMain && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
style={{
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
flexShrink: 0,
}}
>
<circle cx="5" cy="5" r="5" fill="#EC1C24" />
</svg>
)}
</div>
<div className="relative text-sm leading-5 text-gray-600">Выбрать как основной адрес</div>
</div>
)}
</div>
</div>
);
export default ProfileAddressCard;

View File

@ -0,0 +1,41 @@
import React, { useState } from "react";
import AddressForm from "./AddressForm";
import AddressDetails from "./AddressDetails";
interface ProfileAddressWayProps {
onBack: () => void;
}
const ProfileAddressWay = ({ onBack }: ProfileAddressWayProps) => {
const [showDetails, setShowDetails] = useState(false);
const [address, setAddress] = useState("");
return (
<div className="flex relative gap-8 items-start bg-white rounded-2xl flex-[1_0_0] min-h-[860px] max-md:flex-col max-md:gap-5 ">
{/* Левая часть */}
{showDetails ? (
<AddressDetails
onClose={() => setShowDetails(false)}
onBack={onBack}
address={address}
setAddress={setAddress}
/>
) : (
<AddressForm onDetectLocation={() => setShowDetails(true)} address={address} setAddress={setAddress} onBack={onBack} />
)}
{/* Правая часть: карта */}
<div className="flex-1 rounded-2xl overflow-hidden shadow-lg md:w-full ">
<iframe
src="https://yandex.ru/map-widget/v1/?ll=37.532502%2C56.339223&mode=whatshere&whatshere%5Bpoint%5D=37.532502%2C56.339223&whatshere%5Bzoom%5D=17&z=16"
className="w-full h-full min-h-[990px] max-md:min-h-[300px] "
frameBorder="0"
allowFullScreen
title="Карта"
></iframe>
</div>
</div>
);
};
export default ProfileAddressWay;

View File

@ -0,0 +1,239 @@
import React, { useState } from "react";
import AddressFormWithPickup from "./AddressFormWithPickup";
import AddressDetails from "./AddressDetails";
import YandexPickupPointsMap from "../delivery/YandexPickupPointsMap";
import { useLazyQuery } from '@apollo/client';
import {
YANDEX_PICKUP_POINTS_BY_CITY,
YANDEX_PICKUP_POINTS_BY_COORDINATES,
YandexPickupPoint
} from '@/lib/graphql/yandex-delivery';
interface ProfileAddressWayWithMapProps {
onBack: () => void;
editingAddress?: any; // Для редактирования существующего адреса
}
// Координаты городов для центрирования карты
const cityCoordinates: Record<string, [number, number]> = {
'Москва': [55.7558, 37.6176],
'Санкт-Петербург': [59.9311, 30.3609],
'Новосибирск': [55.0084, 82.9357],
'Екатеринбург': [56.8431, 60.6454],
'Казань': [55.8304, 49.0661],
'Нижний Новгород': [56.2965, 43.9361],
'Челябинск': [55.1644, 61.4368],
'Самара': [53.2001, 50.15],
'Омск': [54.9885, 73.3242],
'Ростов-на-Дону': [47.2357, 39.7015],
'Уфа': [54.7388, 55.9721],
'Красноярск': [56.0184, 92.8672],
'Воронеж': [51.6720, 39.1843],
'Пермь': [58.0105, 56.2502],
'Волгоград': [48.7080, 44.5133],
'Краснодар': [45.0355, 38.9753],
'Саратов': [51.5924, 46.0348],
'Тюмень': [57.1522, 65.5272],
'Тольятти': [53.5303, 49.3461],
'Ижевск': [56.8527, 53.2118],
'Барнаул': [53.3606, 83.7636],
'Ульяновск': [54.3142, 48.4031],
'Иркутск': [52.2978, 104.2964],
'Хабаровск': [48.4827, 135.0839],
'Ярославль': [57.6261, 39.8845],
'Владивосток': [43.1056, 131.8735],
'Махачкала': [42.9849, 47.5047],
'Томск': [56.4977, 84.9744],
'Оренбург': [51.7727, 55.0988],
'Кемерово': [55.3331, 86.0833],
'Новокузнецк': [53.7557, 87.1099],
'Рязань': [54.6269, 39.6916],
'Набережные Челны': [55.7558, 52.4069],
'Астрахань': [46.3497, 48.0408],
'Пенза': [53.2001, 45.0000],
'Липецк': [52.6031, 39.5708],
'Тула': [54.1961, 37.6182],
'Киров': [58.6035, 49.6679],
'Чебоксары': [56.1439, 47.2517],
'Калининград': [54.7065, 20.5110],
'Брянск': [53.2434, 34.3640],
'Курск': [51.7373, 36.1873],
'Иваново': [57.0000, 40.9737],
'Магнитогорск': [53.4078, 59.0647],
'Тверь': [56.8587, 35.9176],
'Ставрополь': [45.0428, 41.9734],
'Симферополь': [44.9572, 34.1108],
'Белгород': [50.5951, 36.5804],
'Архангельск': [64.5401, 40.5433],
'Владимир': [56.1366, 40.3966],
'Сочи': [43.6028, 39.7342],
'Курган': [55.4500, 65.3333],
'Смоленск': [54.7818, 32.0401],
'Калуга': [54.5293, 36.2754],
'Чита': [52.0307, 113.5006],
'Орёл': [52.9651, 36.0785],
'Волжский': [48.7854, 44.7759],
'Череповец': [59.1374, 37.9097],
'Владикавказ': [43.0370, 44.6830],
'Мурманск': [68.9792, 33.0925],
'Сургут': [61.2500, 73.4167],
'Вологда': [59.2239, 39.8840],
'Тамбов': [52.7319, 41.4520],
'Стерлитамак': [53.6241, 55.9504],
'Грозный': [43.3181, 45.6942],
'Якутск': [62.0355, 129.6755],
'Кострома': [57.7665, 40.9265],
'Комсомольск-на-Амуре': [50.5496, 137.0067],
'Петрозаводск': [61.7849, 34.3469],
'Таганрог': [47.2362, 38.8969],
'Нижневартовск': [60.9344, 76.5531],
'Йошкар-Ола': [56.6372, 47.8753],
'Братск': [56.1326, 101.6140],
'Новороссийск': [44.7209, 37.7677],
'Дзержинск': [56.2342, 43.4582],
'Шахты': [47.7090, 40.2060],
'Нижнекамск': [55.6367, 51.8209],
'Орск': [51.2045, 58.5434],
'Ангарск': [52.5406, 103.8887],
'Старый Оскол': [51.2965, 37.8411],
'Великий Новгород': [58.5218, 31.2756],
'Благовещенск': [50.2941, 127.5405],
'Прокопьевск': [53.9058, 86.7194],
'Химки': [55.8970, 37.4296],
'Энгельс': [51.4827, 46.1124],
'Рыбинск': [58.0446, 38.8486],
'Балашиха': [55.7969, 37.9386],
'Подольск': [55.4297, 37.5547],
'Королёв': [55.9226, 37.8251],
'Петропавловск-Камчатский': [53.0446, 158.6483],
'Мытищи': [55.9116, 37.7307],
'Люберцы': [55.6758, 37.8939],
'Магадан': [59.5638, 150.8063],
'Норильск': [69.3558, 88.1893],
'Южно-Сахалинск': [46.9588, 142.7386]
};
const ProfileAddressWayWithMap: React.FC<ProfileAddressWayWithMapProps> = ({ onBack, editingAddress }) => {
const [showDetails, setShowDetails] = useState(false);
const [address, setAddress] = useState("");
const [pickupPoints, setPickupPoints] = useState<YandexPickupPoint[]>([]);
const [selectedPickupPoint, setSelectedPickupPoint] = useState<YandexPickupPoint | undefined>();
const [mapCenter, setMapCenter] = useState<[number, number]>([55.7558, 37.6176]); // Москва
const [loadPointsByCity] = useLazyQuery(YANDEX_PICKUP_POINTS_BY_CITY, {
onCompleted: (data) => {
const points = data.yandexPickupPointsByCity || [];
setPickupPoints(points);
// Если есть точки, центрируем карту на первой
if (points.length > 0) {
setMapCenter([points[0].position.latitude, points[0].position.longitude]);
}
},
onError: (error) => {
console.error('Ошибка загрузки ПВЗ по городу:', error);
setPickupPoints([]);
},
errorPolicy: 'all'
});
const [loadPointsByCoordinates] = useLazyQuery(YANDEX_PICKUP_POINTS_BY_COORDINATES, {
onCompleted: (data) => {
const points = data.yandexPickupPointsByCoordinates || [];
setPickupPoints(points);
},
onError: (error) => {
console.error('Ошибка загрузки ПВЗ по координатам:', error);
setPickupPoints([]);
},
errorPolicy: 'all'
});
// Загружаем ПВЗ для Москвы при первой загрузке (где есть много ПВЗ)
React.useEffect(() => {
loadPointsByCity({ variables: { cityName: 'Москва' } });
}, [loadPointsByCity]);
const handlePickupPointSelect = (point: YandexPickupPoint) => {
setSelectedPickupPoint(point);
setAddress(point.address.fullAddress);
setMapCenter([point.position.latitude, point.position.longitude]);
};
const handleDetectLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
setMapCenter([lat, lon]);
loadPointsByCoordinates({
variables: {
latitude: lat,
longitude: lon,
radiusKm: 15
}
});
},
(error) => {
console.error('Ошибка определения местоположения:', error);
alert('Не удалось определить местоположение');
}
);
} else {
alert('Геолокация не поддерживается браузером');
}
};
const handleCityChange = (cityName: string) => {
// Сначала центрируем карту на выбранном городе
const coordinates = cityCoordinates[cityName];
if (coordinates) {
setMapCenter(coordinates);
}
// Затем загружаем ПВЗ для города
loadPointsByCity({ variables: { cityName } });
};
return (
<div className="flex relative gap-8 items-start bg-white rounded-2xl flex-[1_0_0] max-md:flex-col max-md:gap-5">
{/* Левая часть */}
{showDetails ? (
<AddressDetails
onClose={() => setShowDetails(false)}
onBack={onBack}
address={address}
setAddress={setAddress}
/>
) : (
<AddressFormWithPickup
onDetectLocation={handleDetectLocation}
address={address}
setAddress={setAddress}
onBack={onBack}
onCityChange={handleCityChange}
onPickupPointSelect={handlePickupPointSelect}
selectedPickupPoint={selectedPickupPoint}
editingAddress={editingAddress}
/>
)}
{/* Правая часть: карта */}
<div className="flex-1 min-w-0 w-full rounded-2xl md:w-full max-md:h-[320px] max-md:min-h-0">
<YandexPickupPointsMap
pickupPoints={pickupPoints}
selectedPoint={selectedPickupPoint}
onPointSelect={handlePickupPointSelect}
center={mapCenter}
zoom={12}
className="w-full h-[220px] md:min-h-[990px] md:h-full"
/>
</div>
</div>
);
};
export default ProfileAddressWayWithMap;

View File

@ -0,0 +1,155 @@
import * as React from "react";
import { useQuery, useMutation } from "@apollo/client";
import ProfileAddressCard from "./ProfileAddressCard";
import ProfileAddressWayWithMap from "./ProfileAddressWayWithMap";
import { GET_CLIENT_DELIVERY_ADDRESSES, DELETE_CLIENT_DELIVERY_ADDRESS } from "@/lib/graphql";
interface DeliveryAddress {
id: string;
name: string;
address: string;
deliveryType: 'COURIER' | 'PICKUP' | 'POST' | 'TRANSPORT';
comment?: string;
entrance?: string;
floor?: string;
apartment?: string;
intercom?: string;
deliveryTime?: string;
contactPhone?: string;
createdAt: string;
updatedAt: string;
}
const getDeliveryTypeLabel = (type: string) => {
const labels = {
COURIER: 'Доставка курьером',
PICKUP: 'Самовывоз',
POST: 'Почта России',
TRANSPORT: 'Транспортная компания'
};
return labels[type as keyof typeof labels] || type;
};
const ProfileAddressesMain = () => {
const [mainIndex, setMainIndex] = React.useState(0);
const [showWay, setShowWay] = React.useState(false);
const [editingAddress, setEditingAddress] = React.useState<DeliveryAddress | null>(null);
const { data, loading, error, refetch } = useQuery(GET_CLIENT_DELIVERY_ADDRESSES, {
errorPolicy: 'all'
});
const [deleteAddress] = useMutation(DELETE_CLIENT_DELIVERY_ADDRESS, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка удаления адреса:', error);
alert('Ошибка удаления адреса: ' + error.message);
}
});
const handleDeleteAddress = async (addressId: string) => {
if (confirm('Вы уверены, что хотите удалить этот адрес?')) {
try {
await deleteAddress({
variables: { id: addressId }
});
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
const handleEditAddress = (address: DeliveryAddress) => {
setEditingAddress(address);
setShowWay(true);
};
const handleWayClose = () => {
setShowWay(false);
setEditingAddress(null);
refetch(); // Обновляем данные после закрытия формы
};
if (showWay) return (
<ProfileAddressWayWithMap
onBack={handleWayClose}
editingAddress={editingAddress}
/>
);
if (loading) {
return (
<div className="flex relative flex-col gap-8 items-start p-8 bg-white rounded-2xl flex-[1_0_0] max-md:gap-5 ">
<div className="text-center text-gray-500">Загрузка адресов...</div>
</div>
);
}
if (error) {
return (
<div className="flex relative flex-col gap-8 items-start p-8 bg-white rounded-2xl flex-[1_0_0] max-md:gap-5 ">
<div className="text-center text-red-500">
<div className="mb-2">Ошибка загрузки адресов</div>
<div className="text-sm text-gray-500 mb-4">{error.message}</div>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Попробовать снова
</button>
</div>
</div>
);
}
const addresses = data?.clientMe?.deliveryAddresses || [];
return (
<div className="flex relative flex-col gap-8 w-full items-start p-8 bg-white rounded-2xl flex-[1_0_0] max-md:gap-5 ">
{addresses.length > 0 ? (
<div className="flex flex-wrap gap-5 items-start self-stretch">
{addresses.map((addr: DeliveryAddress, idx: number) => (
<ProfileAddressCard
key={addr.id}
type={getDeliveryTypeLabel(addr.deliveryType)}
title={addr.name}
address={addr.address}
comment={addr.comment}
onEdit={() => handleEditAddress(addr)}
onSelectMain={() => setMainIndex(idx)}
onDelete={() => handleDeleteAddress(addr.id)}
isMain={mainIndex === idx}
/>
))}
</div>
) : (
<div
className="flex items-center justify-center w-full h-[380px] max-w-[400px] bg-[#eaf0f8] rounded-2xl text-xl font-semibold text-gray-900 cursor-pointer select-none"
onClick={() => setShowWay(true)}
>
+ Добавить адрес
</div>
)}
{addresses.length > 0 && (
<div
layer-name="Button Small"
className="flex relative gap-2.5 justify-center items-center px-5 py-3.5 bg-red-600 rounded-xl h-[50px] cursor-pointer hover:bg-red-700 transition-colors"
onClick={() => setShowWay(true)}
>
<div
layer-name="Button Small"
className="relative text-base font-medium leading-5 text-center text-white "
>
Добавить адрес доставки
</div>
</div>
)}
</div>
);
};
export default ProfileAddressesMain;

View File

@ -0,0 +1,267 @@
import * as React from "react";
const ProfileAnnouncementMain = () => {
const [search, setSearch] = React.useState("");
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center px-8 py-3 w-full text-base leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск уведомлений"
className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full bg-transparent outline-none placeholder-gray-400"
/>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/2b8e5dde8809a16af6b9b2f399617f9bd340e40c?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square"
/>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Важное
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-col justify-center px-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center text-red-600">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/31621e15429b14d49586c2261c65e539112ef134?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-red-600 text-ellipsis">
Больше не важно
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 text-gray-600 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center px-5 py-3 mt-2.5 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center text-red-600">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/31621e15429b14d49586c2261c65e539112ef134?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-red-600 text-ellipsis">
Больше не важно
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 text-gray-600 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Все уведомления
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug text-gray-600 max-md:max-w-full">
<div className="flex flex-col justify-center px-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/7683538c3cf5a8a683c81e126b030648d832fb0a?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600 text-ellipsis">
Пометить как важное
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center px-5 py-3 mt-2.5 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/7683538c3cf5a8a683c81e126b030648d832fb0a?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600 text-ellipsis">
Пометить как важное
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col justify-center px-5 py-3 mt-2.5 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap justify-between items-start w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-wrap flex-1 shrink gap-5 items-start pr-8 basis-0 min-h-[20px] min-w-[240px] max-md:max-w-full">
<div className="cursor-pointer flex gap-1.5 items-center">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/7683538c3cf5a8a683c81e126b030648d832fb0a?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600 text-ellipsis">
Пометить как важное
</div>
</div>
<div className="font-bold leading-none whitespace-nowrap text-ellipsis text-gray-950 w-[269px]">
Скидка на все товары Hett Automotive
</div>
<div className="flex-1 shrink text-gray-600 whitespace-nowrap basis-0 text-ellipsis max-md:max-w-full">
Только до 31 апреля успейте приобрести качественные товары со
скидкой до 50% от Hett Automotive
</div>
</div>
<div className="flex gap-5 items-center pr-2.5 whitespace-nowrap">
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<div className="self-stretch my-auto text-gray-600">
Развернуть
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/3aab326226184071a16336e722a5902d5446fd0b?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square"
/>
</div>
<div className="cursor-pointer flex gap-1.5 items-center self-stretch my-auto">
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/84d525d7bd06a6d1614a61af6453f489170b4196?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default ProfileAnnouncementMain;

View File

@ -0,0 +1,168 @@
import React, { useState } from "react";
type ProfileBalanceCardProps = {
contractId: string;
orgName: string;
contract: string;
balance: string;
limit: string;
limitLeft: string;
ordersSum: string;
days: string;
daysLeft: string;
paid: string;
inputValue: string;
buttonLabel: string;
onTopUp: (contractId: string, amount: number) => Promise<void>;
isOverLimit?: boolean;
isCreatingInvoice?: boolean;
};
const ProfileBalanceCard: React.FC<ProfileBalanceCardProps> = ({
contractId,
orgName,
contract,
balance,
limit,
limitLeft,
ordersSum,
days,
daysLeft,
paid,
inputValue,
buttonLabel,
onTopUp,
isOverLimit = false,
isCreatingInvoice = false
}) => {
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
}
}, [editing]);
const handleBlur = () => setEditing(false);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") setEditing(false);
};
const handleTopUp = async () => {
const amount = parseFloat(value.replace(/[^\d.,]/g, '').replace(',', '.'));
if (isNaN(amount) || amount <= 0) {
alert('Введите корректную сумму для пополнения');
return;
}
if (isCreatingInvoice) {
return; // Не делаем ничего, если уже создается счет
}
setLoading(true);
try {
await onTopUp(contractId, amount);
setValue("");
setEditing(false);
} catch (error) {
console.error('Ошибка пополнения:', error);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col flex-1 shrink justify-between p-8 bg-white rounded-lg border border-solid basis-0 border-stone-300 max-w-[404px] sm:min-w-[340px] min-w-[200px] max-md:px-5">
<div className="flex flex-col w-full leading-snug text-gray-950">
<div className="text-xl font-bold text-gray-950">{orgName}</div>
<div className="mt-1.5 text-base text-gray-950">{contract}</div>
</div>
<div className="flex flex-col mt-4 w-full">
<div className="flex flex-col w-full">
<div className="flex flex-col w-full">
<div className="text-sm leading-snug text-gray-600">Баланс</div>
<div className={`mt-2 text-2xl font-bold leading-none ${balance.startsWith('-') ? 'text-red-600' : 'text-gray-950'}`}>
{balance}
</div>
</div>
<div className="flex flex-row gap-5 items-end mt-5 w-full max-sm:flex-col">
<div className="flex flex-col flex-1 shrink basis-0">
<div className="flex flex-col min-w-[160px]">
<div className="text-sm leading-snug text-gray-600">Лимит отсрочки</div>
<div className="flex flex-col self-start mt-2">
<div className="text-lg font-medium leading-none text-gray-950">{limit}</div>
<div className={`text-sm leading-snug ${isOverLimit ? 'text-red-600' : 'text-gray-600'}`}>
{limitLeft.includes('Не установлен') ? limitLeft : `Осталось ${limitLeft}`}
</div>
</div>
</div>
<div className="flex flex-col mt-5 min-w-[160px]">
<div className="text-sm leading-snug text-gray-600">Сумма заказов</div>
<div className="mt-2 text-lg font-medium leading-none text-gray-950">{ordersSum}</div>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0">
<div className="flex flex-col min-w-[160px]">
<div className="text-lg font-medium leading-none text-gray-950">{days}</div>
<div className={`text-sm leading-snug ${daysLeft.includes("Осталось") && balance.startsWith('-') ? "text-red-600" : "text-gray-600"}`}>
{daysLeft}
</div>
</div>
<div className="flex flex-col mt-5 min-w-[160px]">
<div className="text-sm leading-snug text-gray-600">Оплачено</div>
<div className="mt-2 text-lg font-medium leading-none text-gray-950">{paid}</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col mt-8 w-full">
{editing ? (
<input
ref={inputRef}
type="text"
value={value}
onChange={e => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="Введите сумму пополнения"
className="gap-2.5 self-stretch px-6 py-4 w-full text-sm leading-snug bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-950 max-md:px-5 outline-none focus:ring-0 focus:border-stone-400 placeholder-neutral-500"
/>
) : (
<div
className={`gap-2.5 self-stretch px-6 py-4 w-full text-sm leading-snug bg-white rounded border border-solid border-stone-300 min-h-[52px] max-md:px-5 cursor-text ${!value ? "text-neutral-500" : "text-gray-950"}`}
onClick={() => setEditing(true)}
>
{value || "Введите сумму пополнения"}
</div>
)}
<div
role="button"
tabIndex={0}
aria-disabled={loading || isCreatingInvoice || !value.trim()}
onClick={() => {
if (!(loading || isCreatingInvoice || !value.trim())) handleTopUp();
}}
onKeyDown={e => {
if ((e.key === 'Enter' || e.key === ' ') && !(loading || isCreatingInvoice || !value.trim())) {
handleTopUp();
}
}}
className={`gap-2.5 self-start px-5 py-4 mt-4 text-base font-medium leading-tight text-center text-white whitespace-nowrap rounded-xl min-h-[50px] transition-colors duration-150
${loading || isCreatingInvoice || !value.trim()
? 'bg-red-300 cursor-not-allowed'
: 'bg-red-600 hover:bg-red-700 cursor-pointer'}
`}
>
{isCreatingInvoice ? 'Создаем счет...' : loading ? 'Пополняем...' : buttonLabel}
</div>
</div>
</div>
</div>
);
};
export default ProfileBalanceCard;

View File

@ -0,0 +1,348 @@
import * as React from "react";
import { useState } from "react";
import { useQuery, useMutation } from '@apollo/client';
import { GET_CLIENT_ME, CREATE_BALANCE_INVOICE } from '@/lib/graphql';
import toast from 'react-hot-toast';
import ProfileBalanceCard from "./ProfileBalanceCard";
interface LegalEntity {
id: string;
shortName: string;
fullName: string;
form: string;
inn: string;
}
interface Contract {
id: string;
contractNumber: string;
contractDate: string;
name: string;
ourLegalEntity: string;
clientLegalEntity: string;
balance: number;
currency: string;
isActive: boolean;
isDefault: boolean;
contractType: string;
relationship: string;
paymentDelay: boolean;
creditLimit?: number;
delayDays?: number;
fileUrl?: string;
createdAt: string;
updatedAt: string;
}
interface ClientData {
id: string;
name: string;
email?: string;
phone: string;
legalEntities: LegalEntity[];
contracts: Contract[];
}
const ProfileBalanceMain = () => {
const [isCreatingInvoice, setIsCreatingInvoice] = useState(false);
const { data, loading, error, refetch } = useQuery(GET_CLIENT_ME, {
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
}
});
const [createBalanceInvoice] = useMutation(CREATE_BALANCE_INVOICE, {
onCompleted: async (data) => {
console.log('Счет на пополнение создан:', data.createBalanceInvoice);
const invoice = data.createBalanceInvoice;
// Проверяем, что счет создан корректно
if (!invoice || !invoice.id) {
toast.error('Ошибка: некорректные данные счета');
setIsCreatingInvoice(false);
return;
}
try {
// Получаем токен так же, как в Apollo Client
let token = null;
const userData = localStorage.getItem('userData');
if (userData) {
try {
const user = JSON.parse(userData);
if (!user.id) {
throw new Error('Отсутствует ID пользователя');
}
// Создаем токен в формате, который ожидает CMS
token = `client_${user.id}`;
} catch (error) {
console.error('Ошибка парсинга userData:', error);
toast.error('Ошибка получения данных пользователя');
setIsCreatingInvoice(false);
return;
}
}
if (!token) {
toast.error('Ошибка авторизации. Попробуйте перезайти.');
setIsCreatingInvoice(false);
return;
}
// Скачиваем PDF с токеном авторизации
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const downloadUrl = `${baseUrl}/api/invoice/${invoice.id}`;
if (!baseUrl) {
throw new Error('Не настроен URL API сервера');
}
const response = await fetch(downloadUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
} catch (jsonError) {
// Если не удалось распарсить JSON, используем текст ответа
try {
const errorText = await response.text();
if (errorText) {
errorMessage = errorText.substring(0, 100); // Ограничиваем длину
}
} catch (textError) {
// Если и текст не удалось получить, используем стандартное сообщение
errorMessage = `Ошибка ${response.status}: ${response.statusText}`;
}
}
throw new Error(errorMessage);
}
// Создаем blob и скачиваем файл
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${invoice.invoiceNumber}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
// Показываем уведомление об успехе
toast.success(`Счет ${invoice.invoiceNumber} создан и загружен!`, {
duration: 5000,
icon: '📄'
});
} catch (error) {
console.error('Ошибка скачивания PDF:', error);
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка';
toast.error(`Ошибка скачивания PDF: ${errorMessage}`);
}
setIsCreatingInvoice(false);
refetch();
},
onError: (error) => {
console.error('Ошибка создания счета:', error);
toast.error('Ошибка создания счета: ' + error.message);
setIsCreatingInvoice(false);
}
});
const handleCreateInvoice = async (contractId: string, amount: number) => {
if (isCreatingInvoice) return;
setIsCreatingInvoice(true);
const loadingToast = toast.loading('Создаем счет на оплату...');
try {
const { data } = await createBalanceInvoice({
variables: {
contractId: contractId,
amount: amount
}
});
if (data?.createBalanceInvoice) {
toast.dismiss(loadingToast);
// Логика скачивания уже в onCompleted мутации
// Обновляем данные
await refetch();
}
} catch (error) {
toast.dismiss(loadingToast);
console.error('Ошибка создания счета:', error);
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка';
toast.error(`Ошибка создания счета: ${errorMessage}`);
setIsCreatingInvoice(false);
}
};
if (loading) {
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка данных...</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm">{error.message}</div>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Повторить
</button>
</div>
</div>
</div>
</div>
);
}
const clientData: ClientData | null = data?.clientMe || null;
// Проверяем есть ли у клиента юридические лица
if (!clientData?.legalEntities?.length) {
return (
<div className="flex flex-col justify-center">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="text-center">
<div className="text-lg font-semibold mb-2">Нет доступа к балансам</div>
<div className="text-sm text-gray-600 mb-4">
Для работы с балансами необходимо добавить юридическое лицо в настройках профиля
</div>
<a
href="/profile-settings"
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Перейти к настройкам
</a>
</div>
</div>
</div>
</div>
);
}
// Проверяем есть ли договоры
if (!clientData?.contracts?.length) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-col justify-center items-center p-8">
<div className="text-center">
<div className="text-lg font-semibold mb-2">Нет активных договоров</div>
<div className="text-sm text-gray-600 mb-4">
Договоры с балансами будут созданы менеджером после подтверждения ваших юридических лиц
</div>
<div className="text-sm text-gray-500">
Обратитесь к менеджеру для создания договоров с возможностью покупки в долг
</div>
</div>
</div>
</div>
</div>
);
}
const formatCurrency = (amount: number, currency: string = 'RUB') => {
return `${amount.toLocaleString('ru-RU')} ${currency === 'RUB' ? '₽' : currency}`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU');
};
const calculateDaysLeft = (delayDays?: number) => {
if (!delayDays) return 'Без ограничений';
// Здесь должна быть логика расчета оставшихся дней
// Пока возвращаем статичное значение
return `Осталось ${Math.max(0, delayDays)} дней`;
};
const getLegalEntityName = (clientLegalEntity: string) => {
// Если поле пустое или null, показываем первое доступное юридическое лицо
if (!clientLegalEntity || clientLegalEntity.trim() === '') {
return clientData?.legalEntities?.[0]?.shortName || 'Не указано';
}
// Очищаем строку от лишних кавычек
const cleanedName = clientLegalEntity.replace(/^"(.*)"$/, '$1');
// Ищем по названию или ID
const entity = clientData?.legalEntities?.find(le =>
le.shortName === clientLegalEntity ||
le.shortName === cleanedName ||
le.id === clientLegalEntity ||
le.fullName === clientLegalEntity ||
le.fullName === cleanedName
);
return entity ? entity.shortName : cleanedName;
};
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex overflow-hidden flex-col justify-center p-8 w-full bg-white rounded-2xl min-h-[543px] max-md:px-5 max-md:max-w-full">
<div className="flex flex-wrap flex-1 gap-5 size-full max-md:max-w-full">
{clientData.contracts.filter(contract => contract.isActive).map((contract) => {
const hasLimit = contract.creditLimit !== null && contract.creditLimit !== undefined;
const limitLeft = hasLimit ? Math.max(0, (contract.creditLimit || 0) + contract.balance) : 0;
const isOverLimit = contract.balance < 0 && hasLimit && Math.abs(contract.balance) > (contract.creditLimit || 0);
return (
<ProfileBalanceCard
key={contract.id}
contractId={contract.id}
orgName={getLegalEntityName(contract.clientLegalEntity)}
contract={`Договор № ${contract.contractNumber} от ${formatDate(contract.contractDate)}`}
balance={formatCurrency(contract.balance, contract.currency)}
limit={hasLimit ? formatCurrency(contract.creditLimit || 0, contract.currency) : 'Не установлен'}
limitLeft={hasLimit ? formatCurrency(limitLeft, contract.currency) : 'Не установлен'}
ordersSum="0 ₽" // TODO: Добавить расчет суммы заказов
days={contract.delayDays ? `${contract.delayDays} дней` : 'Без ограничений'}
daysLeft={calculateDaysLeft(contract.delayDays)}
paid="0 ₽" // TODO: Добавить расчет оплаченной суммы
inputValue="0 ₽"
buttonLabel="Пополнить"
onTopUp={handleCreateInvoice}
isOverLimit={Boolean(isOverLimit)}
isCreatingInvoice={isCreatingInvoice}
/>
);
})}
</div>
</div>
</div>
);
}
export default ProfileBalanceMain;

View File

@ -0,0 +1,446 @@
import * as React from "react";
import { useQuery, useMutation, useLazyQuery } from '@apollo/client';
import {
GET_USER_VEHICLES,
GET_VEHICLE_SEARCH_HISTORY,
CREATE_VEHICLE_FROM_VIN,
DELETE_USER_VEHICLE,
ADD_VEHICLE_FROM_SEARCH,
DELETE_SEARCH_HISTORY_ITEM,
UserVehicle,
VehicleSearchHistory
} from '@/lib/graphql/garage';
import { FIND_LAXIMO_VEHICLE_GLOBAL } from '@/lib/graphql';
import { LaximoVehicleSearchResult } from '@/types/laximo';
const ProfileGarageMain = () => {
const [searchQuery, setSearchQuery] = React.useState("");
const [vin, setVin] = React.useState("");
const [carComment, setCarComment] = React.useState("");
const [showAddCar, setShowAddCar] = React.useState(false);
const [expandedVehicle, setExpandedVehicle] = React.useState<string | null>(null);
const [isAddingVehicle, setIsAddingVehicle] = React.useState(false);
// GraphQL queries and mutations
const { data: vehiclesData, loading: vehiclesLoading, refetch: refetchVehicles } = useQuery(GET_USER_VEHICLES);
const { data: historyData, loading: historyLoading, refetch: refetchHistory } = useQuery(GET_VEHICLE_SEARCH_HISTORY);
const [searchVehicleByVin] = useLazyQuery(FIND_LAXIMO_VEHICLE_GLOBAL);
const [createVehicleFromVin] = useMutation(CREATE_VEHICLE_FROM_VIN, {
onCompleted: () => {
refetchVehicles();
setVin('');
setCarComment('');
setShowAddCar(false);
setIsAddingVehicle(false);
},
onError: (error) => {
console.error('Ошибка создания автомобиля:', error);
alert('Ошибка при добавлении автомобиля');
setIsAddingVehicle(false);
}
});
const [deleteVehicle] = useMutation(DELETE_USER_VEHICLE, {
onCompleted: () => refetchVehicles(),
onError: (error) => {
console.error('Ошибка удаления автомобиля:', error);
alert('Ошибка при удалении автомобиля');
}
});
const [addFromSearch] = useMutation(ADD_VEHICLE_FROM_SEARCH, {
onCompleted: () => {
refetchVehicles();
refetchHistory();
},
onError: (error) => {
console.error('Ошибка добавления из истории:', error);
alert('Ошибка при добавлении автомобиля из истории');
}
});
const [deleteHistoryItem] = useMutation(DELETE_SEARCH_HISTORY_ITEM, {
onCompleted: () => refetchHistory(),
onError: (error) => {
console.error('Ошибка удаления истории:', error);
alert('Ошибка при удалении из истории');
}
});
const vehicles: UserVehicle[] = vehiclesData?.userVehicles || [];
const searchHistory: VehicleSearchHistory[] = historyData?.vehicleSearchHistory || [];
// Фильтрация автомобилей по поисковому запросу
const filteredVehicles = vehicles.filter(vehicle =>
vehicle.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
vehicle.vin?.toLowerCase().includes(searchQuery.toLowerCase()) ||
vehicle.brand?.toLowerCase().includes(searchQuery.toLowerCase()) ||
vehicle.model?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSaveVehicle = async () => {
if (!vin.trim()) {
alert('Введите VIN номер');
return;
}
setIsAddingVehicle(true);
try {
await createVehicleFromVin({
variables: {
vin: vin.trim().toUpperCase(),
comment: carComment.trim() || null
}
});
} catch (error) {
console.error('Ошибка сохранения автомобиля:', error);
}
};
const handleDeleteVehicle = async (vehicleId: string) => {
if (confirm('Вы уверены, что хотите удалить этот автомобиль?')) {
try {
await deleteVehicle({ variables: { id: vehicleId } });
} catch (error) {
console.error('Ошибка удаления автомобиля:', error);
}
}
};
const handleAddFromHistory = async (historyItem: VehicleSearchHistory) => {
try {
await addFromSearch({
variables: {
vin: historyItem.vin,
comment: ''
}
});
} catch (error) {
console.error('Ошибка добавления из истории:', error);
}
};
const handleDeleteFromHistory = async (historyId: string) => {
try {
await deleteHistoryItem({ variables: { id: historyId } });
} catch (error) {
console.error('Ошибка удаления истории:', error);
}
};
const handleFindParts = (vehicle: UserVehicle) => {
// Переход к поиску запчастей для автомобиля
if (vehicle.vin) {
window.location.href = `/vehicle-search-results?q=${encodeURIComponent(vehicle.vin)}`;
}
};
const toggleVehicleExpanded = (vehicleId: string) => {
setExpandedVehicle(expandedVehicle === vehicleId ? null : vehicleId);
};
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center px-8 py-3 w-full text-base leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full">
<div className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full">
<input
type="text"
placeholder="Поиск по гаражу"
className="w-full bg-transparent outline-none text-gray-400"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<img
loading="lazy"
src="/images/search_ixon.svg"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square"
/>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Мои автомобили
</div>
{vehiclesLoading && (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
</div>
)}
{!vehiclesLoading && filteredVehicles.length === 0 && !showAddCar && (
<div className="text-center py-8 text-gray-500">
{vehicles.length === 0 ? 'У вас пока нет автомобилей в гараже' : 'Автомобили не найдены'}
</div>
)}
{!vehiclesLoading && filteredVehicles.map((vehicle) => (
<div key={vehicle.id} className="mt-8">
<div className="flex flex-col justify-center pr-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full">
<div className="flex flex-wrap gap-8 items-center w-full max-md:max-w-full">
<div className="flex gap-8 items-center self-stretch my-auto min-w-[240px] max-md:flex-col max-md:min-w-0 max-md:gap-2">
<div className="self-stretch my-auto text-xl font-bold leading-none text-gray-950">
{vehicle.name || `${vehicle.brand || ''} ${vehicle.model || ''}`.trim() || 'Неизвестный автомобиль'}
</div>
<div className="self-stretch my-auto text-sm leading-snug text-gray-600 max-md:whitespace-normal">
{vehicle.vin || 'VIN не указан'}
</div>
</div>
<div className="flex-1 shrink gap-2.5 self-stretch px-3.5 py-1.5 my-auto text-sm leading-snug whitespace-nowrap bg-white rounded border border-solid basis-3 border-zinc-100 min-h-[32px] min-w-[240px] text-stone-500 truncate overflow-hidden">
{vehicle.comment || 'Комментарий не добавлен'}
</div>
<div
className="gap-2.5 self-stretch px-5 py-2 my-auto font-medium leading-tight text-center bg-red-600 rounded-lg min-h-[32px] cursor-pointer text-white hover:bg-red-700 transition-colors"
role="button"
tabIndex={0}
onClick={() => handleFindParts(vehicle)}
>
Найти запчасть
</div>
<div className="flex gap-5 items-center self-stretch pr-2.5 my-auto text-sm leading-snug text-gray-600 whitespace-nowrap">
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer text-sm leading-snug text-gray-600 hover:text-red-600 transition-colors"
onClick={() => handleDeleteVehicle(vehicle.id)}
>
<img
loading="lazy"
src="/images/delete.svg"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<span className="self-stretch my-auto text-gray-600">
Удалить
</span>
</button>
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer text-sm leading-snug text-gray-600 hover:text-blue-600 transition-colors"
onClick={() => toggleVehicleExpanded(vehicle.id)}
>
<span className="self-stretch my-auto text-gray-600">
{expandedVehicle === vehicle.id ? 'Свернуть' : 'Развернуть'}
</span>
<img
loading="lazy"
src="/images/arrow_drop.svg"
className={`object-contain shrink-0 self-stretch my-auto w-3.5 aspect-square transition-transform ${
expandedVehicle === vehicle.id ? 'rotate-180' : ''
}`}
/>
</button>
</div>
</div>
</div>
{/* Расширенная информация об автомобиле */}
{expandedVehicle === vehicle.id && (
<div className="mt-4 px-5 py-4 bg-white rounded-lg border border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
{vehicle.brand && (
<div>
<span className="font-medium text-gray-700">Бренд:</span>
<span className="ml-2 text-gray-900">{vehicle.brand}</span>
</div>
)}
{vehicle.model && (
<div>
<span className="font-medium text-gray-700">Модель:</span>
<span className="ml-2 text-gray-900">{vehicle.model}</span>
</div>
)}
{vehicle.modification && (
<div>
<span className="font-medium text-gray-700">Модификация:</span>
<span className="ml-2 text-gray-900">{vehicle.modification}</span>
</div>
)}
{vehicle.year && (
<div>
<span className="font-medium text-gray-700">Год:</span>
<span className="ml-2 text-gray-900">{vehicle.year}</span>
</div>
)}
{vehicle.frame && (
<div>
<span className="font-medium text-gray-700">Номер кузова:</span>
<span className="ml-2 text-gray-900">{vehicle.frame}</span>
</div>
)}
{vehicle.licensePlate && (
<div>
<span className="font-medium text-gray-700">Госномер:</span>
<span className="ml-2 text-gray-900">{vehicle.licensePlate}</span>
</div>
)}
{vehicle.mileage && (
<div>
<span className="font-medium text-gray-700">Пробег:</span>
<span className="ml-2 text-gray-900">{vehicle.mileage.toLocaleString()} км</span>
</div>
)}
<div>
<span className="font-medium text-gray-700">Добавлен:</span>
<span className="ml-2 text-gray-900">
{new Date(vehicle.createdAt).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
</div>
)}
</div>
))}
{!showAddCar && (
<div className="flex mt-8">
<div
className="gap-2.5 self-stretch px-5 py-4 bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white text-base font-medium leading-tight text-center"
role="button"
tabIndex={0}
onClick={() => setShowAddCar(true)}
>
Добавить авто
</div>
</div>
)}
{showAddCar && (
<>
<div className="mt-8 text-3xl font-bold leading-none text-gray-950">
Добавить авто в гараж
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug whitespace-nowrap text-gray-950 max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-start w-full min-h-[78px] max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">VIN</div>
<div className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-950 max-md:px-5 max-md:max-w-full">
<input
type="text"
placeholder="VIN"
className="w-full bg-transparent outline-none text-gray-950"
value={vin}
onChange={e => setVin(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">Комментарий</div>
<div className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-950 max-md:px-5 max-md:max-w-full">
<input
type="text"
placeholder="Комментарий"
className="w-full bg-transparent outline-none text-gray-950"
value={carComment}
onChange={e => setCarComment(e.target.value)}
/>
</div>
</div>
</div>
</div>
<div className="flex gap-8 items-start self-start mt-8 text-base font-medium leading-tight text-center whitespace-nowrap">
<div
className={`gap-2.5 self-stretch px-5 py-4 rounded-xl min-h-[50px] cursor-pointer text-white transition-colors ${
isAddingVehicle
? 'bg-gray-400 cursor-not-allowed'
: 'bg-red-600 hover:bg-red-700'
}`}
role="button"
tabIndex={0}
onClick={handleSaveVehicle}
>
{isAddingVehicle ? 'Сохранение...' : 'Сохранить'}
</div>
<div
className="gap-2.5 self-stretch px-5 py-4 rounded-xl border border-red-600 min-h-[50px] cursor-pointer bg-white text-gray-950 hover:bg-gray-50 transition-colors"
role="button"
tabIndex={0}
onClick={() => {
setShowAddCar(false);
setVin('');
setCarComment('');
}}
>
Отменить
</div>
</div>
</>
)}
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Ранее вы искали
</div>
{historyLoading && (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
</div>
)}
{!historyLoading && searchHistory.length === 0 && (
<div className="text-center py-8 text-gray-500">
История поиска пуста
</div>
)}
{!historyLoading && searchHistory.length > 0 && (
<div className="flex flex-col mt-8 w-full max-md:max-w-full">
{searchHistory.map((historyItem) => (
<div key={historyItem.id} className="flex flex-col justify-center px-5 py-3 mb-2.5 w-full rounded-lg bg-slate-50 min-h-[44px] max-md:max-w-full">
<div className="flex flex-wrap gap-10 justify-between items-center w-full max-md:max-w-full">
<div className="flex gap-8 items-center self-stretch my-auto min-w-[240px] max-md:flex-col max-md:min-w-0 max-md:gap-2">
<div className="self-stretch my-auto text-lg font-bold leading-none text-gray-950">
{historyItem.brand && historyItem.model
? `${historyItem.brand} ${historyItem.model}`
: 'Автомобиль найден'}
</div>
<div className="self-stretch my-auto text-sm leading-snug text-gray-600">
{historyItem.vin}
</div>
</div>
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto text-sm leading-snug text-gray-600 cursor-pointer bg-transparent hover:text-green-600 transition-colors"
onClick={() => handleAddFromHistory(historyItem)}
>
<img
loading="lazy"
src="/images/add.svg"
className="object-contain shrink-0 self-stretch my-auto w-4 aspect-square"
/>
<span className="self-stretch my-auto text-gray-600">
Добавить в гараж
</span>
</button>
<div className="flex gap-5 items-center self-stretch pr-2.5 my-auto text-sm leading-snug text-gray-600 whitespace-nowrap">
<div className="self-stretch my-auto text-gray-600">
{new Date(historyItem.searchDate).toLocaleDateString('ru-RU')}
</div>
<button
type="button"
className="flex gap-1.5 items-center self-stretch my-auto text-sm leading-snug text-gray-600 cursor-pointer bg-transparent hover:text-red-600 transition-colors"
onClick={() => handleDeleteFromHistory(historyItem.id)}
>
<img
loading="lazy"
src="/images/delete.svg"
className="object-contain shrink-0 self-stretch my-auto aspect-[1.12] w-[18px]"
/>
<span className="self-stretch my-auto text-gray-600">
Удалить
</span>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default ProfileGarageMain;

View File

@ -0,0 +1,111 @@
import React from "react";
interface VehicleInfo {
brand?: string;
model?: string;
year?: number;
}
interface ProfileHistoryItemProps {
id: string;
date: string;
manufacturer: string;
article: string;
name: string;
vehicleInfo?: VehicleInfo;
resultCount?: number;
onDelete?: (id: string) => void;
}
const ProfileHistoryItem: React.FC<ProfileHistoryItemProps> = ({
id,
date,
manufacturer,
article,
name,
vehicleInfo,
resultCount,
onDelete,
}) => {
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDelete) {
onDelete(id);
}
};
const getSearchTypeDisplay = (article: string) => {
if (article.includes('TEXT')) return 'Текстовый поиск';
if (article.includes('ARTICLE')) return 'По артикулу';
if (article.includes('OEM')) return 'По OEM';
if (article.includes('VIN')) return 'Поиск по VIN';
if (article.includes('PLATE')) return 'Поиск по госномеру';
if (article.includes('WIZARD')) return 'Поиск по параметрам';
if (article.includes('PART_VEHICLES')) return 'Поиск авто по детали';
return article;
};
return (
<>
<div className="mt-1.5 w-full border border-gray-200 border-solid min-h-[1px] max-md:max-w-full" />
<div className="flex justify-between items-center px-5 pt-1.5 pb-2 mt-1.5 w-full bg-white rounded-lg max-md:max-w-full max-md:flex-col max-md:min-w-0 hover:bg-gray-50 transition-colors">
<div className="flex flex-wrap flex-1 shrink gap-5 items-center self-stretch pr-5 my-auto w-full basis-0 max-md:max-w-full max-md:flex-col max-md:gap-2 max-md:p-0 max-md:min-w-0">
<div className="self-stretch my-auto w-40 max-md:w-full text-sm">
<div className="font-medium text-gray-900">{date}</div>
{vehicleInfo && (
<div className="text-xs text-gray-500 mt-1">
{vehicleInfo.brand} {vehicleInfo.model} {vehicleInfo.year}
</div>
)}
</div>
<div className="self-stretch my-auto w-40 font-bold leading-snug text-gray-950 max-md:w-full">
{manufacturer}
</div>
<div className="self-stretch my-auto font-medium leading-snug text-gray-700 w-[180px] max-md:w-full text-sm">
{getSearchTypeDisplay(article)}
{resultCount !== undefined && (
<div className="text-xs text-gray-500 mt-1">
Найдено: {resultCount} шт.
</div>
)}
</div>
<div className="flex-1 shrink self-stretch my-auto basis-0 max-md:max-w-full max-md:w-full">
<div className="font-medium text-gray-900">{name}</div>
</div>
{onDelete && (
<div className="w-16 text-center max-md:w-full">
<button
onClick={handleDeleteClick}
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors group"
title="Удалить из истории"
aria-label="Удалить из истории"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-colors"
>
<path d="M3 6h18" className="group-hover:stroke-[#ec1c24]" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" className="group-hover:stroke-[#ec1c24]" />
<path d="M8 6V4c0-1 1-2 2-2h4c-1 0 2 1 2 2v2" className="group-hover:stroke-[#ec1c24]" />
</svg>
</button>
</div>
)}
</div>
</div>
</>
);
};
export default ProfileHistoryItem;

View File

@ -0,0 +1,433 @@
import React, { useState, useEffect } from "react";
import { useQuery, useMutation } from '@apollo/client';
import ProfileHistoryItem from "./ProfileHistoryItem";
import SearchInput from "./SearchInput";
import ProfileHistoryTabs from "./ProfileHistoryTabs";
import {
GET_PARTS_SEARCH_HISTORY,
DELETE_SEARCH_HISTORY_ITEM,
CLEAR_SEARCH_HISTORY,
CREATE_SEARCH_HISTORY_ITEM,
PartsSearchHistoryItem,
PartsSearchHistoryResponse
} from '@/lib/graphql/search-history';
const ProfileHistoryMain = () => {
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState("Все");
const [sortField, setSortField] = useState<"date" | "manufacturer" | "name">("date");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [filteredItems, setFilteredItems] = useState<PartsSearchHistoryItem[]>([]);
const tabOptions = ["Все", "Сегодня", "Вчера", "Эта неделя", "Этот месяц"];
// GraphQL запросы
const { data, loading, error, refetch } = useQuery<{ partsSearchHistory: PartsSearchHistoryResponse }>(
GET_PARTS_SEARCH_HISTORY,
{
variables: { limit: 100, offset: 0 },
fetchPolicy: 'cache-and-network',
onCompleted: (data) => {
console.log('История поиска загружена:', data);
},
onError: (error) => {
console.error('Ошибка загрузки истории поиска:', error);
}
}
);
const [deleteItem] = useMutation(DELETE_SEARCH_HISTORY_ITEM, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка удаления элемента истории:', error);
}
});
const [clearHistory] = useMutation(CLEAR_SEARCH_HISTORY, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка очистки истории:', error);
}
});
const [createHistoryItem] = useMutation(CREATE_SEARCH_HISTORY_ITEM, {
onCompleted: () => {
refetch();
},
onError: (error) => {
console.error('Ошибка создания записи истории:', error);
}
});
const historyItems = data?.partsSearchHistory?.items || [];
// Отладочная информация
console.log('ProfileHistoryMain состояние:', {
loading,
error: error?.message,
data,
historyItemsCount: historyItems.length
});
// Фильтрация по времени
const getFilteredByTime = (items: PartsSearchHistoryItem[], timeFilter: string) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const monthAgo = new Date(today);
monthAgo.setMonth(monthAgo.getMonth() - 1);
switch (timeFilter) {
case "Сегодня":
return items.filter(item => new Date(item.createdAt) >= today);
case "Вчера":
return items.filter(item => {
const itemDate = new Date(item.createdAt);
return itemDate >= yesterday && itemDate < today;
});
case "Эта неделя":
return items.filter(item => new Date(item.createdAt) >= weekAgo);
case "Этот месяц":
return items.filter(item => new Date(item.createdAt) >= monthAgo);
default:
return items;
}
};
// Фильтрация и сортировка
useEffect(() => {
let filtered = [...getFilteredByTime(historyItems, activeTab)];
// Поиск
if (search.trim()) {
const searchLower = search.toLowerCase();
filtered = filtered.filter(item =>
item.searchQuery.toLowerCase().includes(searchLower) ||
item.brand?.toLowerCase().includes(searchLower) ||
item.articleNumber?.toLowerCase().includes(searchLower) ||
item.vehicleInfo?.brand?.toLowerCase().includes(searchLower) ||
item.vehicleInfo?.model?.toLowerCase().includes(searchLower)
);
}
// Сортировка
if (sortField) {
filtered.sort((a, b) => {
let aValue: string | number = '';
let bValue: string | number = '';
switch (sortField) {
case 'date':
aValue = new Date(a.createdAt).getTime();
bValue = new Date(b.createdAt).getTime();
break;
case 'manufacturer':
aValue = a.brand || a.vehicleInfo?.brand || '';
bValue = b.brand || b.vehicleInfo?.brand || '';
break;
case 'name':
aValue = a.searchQuery;
bValue = b.searchQuery;
break;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortOrder === 'asc' ? comparison : -comparison;
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
}
setFilteredItems(filtered);
}, [historyItems, search, activeTab, sortField, sortOrder]);
const handleSort = (field: "date" | "manufacturer" | "name") => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder("desc"); // По умолчанию сначала новые
}
};
const handleDeleteItem = async (id: string) => {
if (window.confirm('Удалить этот элемент из истории?')) {
try {
await deleteItem({ variables: { id } });
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
const handleClearHistory = async () => {
if (window.confirm('Очистить всю историю поиска? Это действие нельзя отменить.')) {
try {
await clearHistory();
} catch (error) {
console.error('Ошибка очистки истории:', error);
}
}
};
const handleCreateTestData = async () => {
const testItems = [
{
searchQuery: "тормозные колодки",
searchType: "TEXT" as const,
brand: "BREMBO",
resultCount: 15,
vehicleBrand: "BMW",
vehicleModel: "X5",
vehicleYear: 2020
},
{
searchQuery: "0986424781",
searchType: "ARTICLE" as const,
brand: "BOSCH",
articleNumber: "0986424781",
resultCount: 3
},
{
searchQuery: "масляный фильтр",
searchType: "TEXT" as const,
brand: "MANN",
resultCount: 22,
vehicleBrand: "AUDI",
vehicleModel: "A4",
vehicleYear: 2018
},
{
searchQuery: "34116858652",
searchType: "OEM" as const,
brand: "BMW",
articleNumber: "34116858652",
resultCount: 8,
vehicleBrand: "BMW",
vehicleModel: "3 Series",
vehicleYear: 2019
},
{
searchQuery: "свечи зажигания",
searchType: "TEXT" as const,
brand: "NGK",
resultCount: 45
}
];
try {
for (const item of testItems) {
await createHistoryItem({
variables: { input: item }
});
// Небольшая задержка между запросами
await new Promise(resolve => setTimeout(resolve, 200));
}
} catch (error) {
console.error('Ошибка создания тестовых данных:', error);
}
};
if (loading && historyItems.length === 0) {
return (
<div className="flex flex-col justify-center text-base min-h-[526px] h-full">
<div className="flex justify-center items-center h-40">
<div className="text-gray-500">Загрузка истории поиска...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center text-base min-h-[526px]">
<div className="flex justify-center items-center h-40">
<div className="text-red-500">Ошибка загрузки истории поиска</div>
</div>
</div>
);
}
return (
<div className="flex flex-col min-h-[526px]">
<div className="flex flex-wrap gap-5 items-center px-8 py-3 w-full leading-snug text-gray-400 whitespace-nowrap bg-white rounded-lg max-md:px-5 max-md:max-w-full max-md:flex-col">
<div className="flex-1 shrink self-stretch my-auto text-gray-400 basis-0 text-ellipsis max-md:max-w-full max-md:w-full">
<SearchInput
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск в истории..."
/>
</div>
<div className="flex gap-2">
{historyItems.length === 0 && (
<button
onClick={handleCreateTestData}
className="px-4 py-2 text-sm text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-50 transition-colors"
>
Создать тестовые данные
</button>
)}
{historyItems.length > 0 && (
<button
onClick={handleClearHistory}
className="px-4 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
>
Очистить историю
</button>
)}
</div>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/02c9461c587bf477e8ee3187cb5faa1bccaf0900?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square max-md:mt-2"
/>
</div>
<div className="flex flex-col mt-5 w-full text-lg font-medium leading-tight whitespace-nowrap text-gray-950 max-md:max-w-full">
<ProfileHistoryTabs tabs={tabOptions} activeTab={activeTab} onTabChange={setActiveTab} />
</div>
<div className="flex flex-col mt-5 w-full text-gray-400 max-md:max-w-full flex-1 h-full">
<div className="flex flex-col p-2 w-full bg-white rounded-xl h-full max-md:max-w-full min-h-[250px] ">
<div className="hidden md:flex gap-10 items-center px-5 py-2 w-full text-sm max-md:max-w-full max-md:flex-col max-md:gap-2 max-md:px-2">
<div className="flex flex-wrap flex-1 shrink gap-5 items-center self-stretch pr-5 my-auto w-full basis-0 min-w-[240px] max-md:max-w-full max-md:flex-col max-md:gap-2 max-md:p-0 max-md:min-w-0">
<div className={`flex gap-1.5 items-center self-stretch my-auto w-40 max-md:w-full ${sortField === 'date' ? 'text-[#ec1c24] font-semibold' : ''}`}>
<div
className="self-stretch my-auto cursor-pointer select-none hover:text-[#ec1c24] transition-colors"
onClick={() => handleSort('date')}
>
Дата и время
</div>
<svg
width="14"
height="14"
fill="none"
viewBox="0 0 20 20"
className="transition-transform duration-200"
style={{
transform: sortField === 'date' && sortOrder === 'asc' ? 'rotate(180deg)' : 'none',
opacity: sortField === 'date' ? 1 : 0.5,
stroke: sortField === 'date' ? '#ec1c24' : '#9CA3AF'
}}
>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className={`flex gap-1.5 items-center self-stretch my-auto w-40 whitespace-nowrap max-md:w-full ${sortField === 'manufacturer' ? 'text-[#ec1c24] font-semibold' : ''}`}>
<div
className="self-stretch my-auto cursor-pointer select-none hover:text-[#ec1c24] transition-colors"
onClick={() => handleSort('manufacturer')}
>
Производитель
</div>
<svg
width="14"
height="14"
fill="none"
viewBox="0 0 20 20"
className="transition-transform duration-200"
style={{
transform: sortField === 'manufacturer' && sortOrder === 'asc' ? 'rotate(180deg)' : 'none',
opacity: sortField === 'manufacturer' ? 1 : 0.5,
stroke: sortField === 'manufacturer' ? '#ec1c24' : '#9CA3AF'
}}
>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="gap-1.5 self-stretch my-auto whitespace-nowrap w-[180px] max-md:w-full">
Артикул/Тип
</div>
<div className={`flex flex-wrap flex-1 shrink gap-1.5 items-center self-stretch my-auto whitespace-nowrap basis-0 max-md:max-w-full max-md:w-full ${sortField === 'name' ? 'text-[#ec1c24] font-semibold' : ''}`}>
<div
className="self-stretch my-auto cursor-pointer select-none hover:text-[#ec1c24] transition-colors"
onClick={() => handleSort('name')}
>
Поисковый запрос
</div>
<svg
width="14"
height="14"
fill="none"
viewBox="0 0 20 20"
className="transition-transform duration-200"
style={{
transform: sortField === 'name' && sortOrder === 'asc' ? 'rotate(180deg)' : 'none',
opacity: sortField === 'name' ? 1 : 0.5,
stroke: sortField === 'name' ? '#ec1c24' : '#9CA3AF'
}}
>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="w-16 text-center max-md:w-full">
Действия
</div>
</div>
</div>
{filteredItems.length === 0 ? (
<div className="flex justify-center items-center py-12 h-full max-md:h-full">
<div className="text-center text-gray-500 h-full">
{historyItems.length === 0 ? (
<>
<div className="text-lg mb-2 " >История поиска пуста</div>
<div className="text-sm">Ваши поисковые запросы будут отображаться здесь</div>
</>
) : (
<>
<div className="text-lg mb-2">Ничего не найдено</div>
<div className="text-sm">Попробуйте изменить фильтры или поисковый запрос</div>
</>
)}
</div>
</div>
) : (
filteredItems.map((item) => (
<ProfileHistoryItem
key={item.id}
id={item.id}
date={new Date(item.createdAt).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
manufacturer={item.brand || item.vehicleInfo?.brand || 'Не указан'}
article={item.articleNumber || `${item.searchType} поиск`}
name={item.searchQuery}
vehicleInfo={item.vehicleInfo}
resultCount={item.resultCount}
onDelete={handleDeleteItem}
/>
))
)}
</div>
{filteredItems.length > 0 && (
<div className="mt-4 text-center text-sm text-gray-500">
Показано {filteredItems.length} из {historyItems.length} записей
</div>
)}
</div>
</div>
);
};
export default ProfileHistoryMain;

View File

@ -0,0 +1,93 @@
import React, { useState, useRef } from "react";
interface ProfileHistoryTabsProps {
tabs: string[];
activeTab: string;
onTabChange: (tab: string) => void;
}
const manufacturers = ["Все", "VAG", "Toyota", "Ford", "BMW"];
const ProfileHistoryTabs: React.FC<ProfileHistoryTabsProps> = ({
tabs,
activeTab,
onTabChange,
}) => {
const [selectedManufacturer, setSelectedManufacturer] = useState(manufacturers[0]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Закрытие дропдауна при клике вне
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}
if (isDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isDropdownOpen]);
return (
<div className="flex flex-wrap gap-5 w-full max-md:max-w-full">
{tabs.map((tab) => (
<div
key={tab}
className={`flex flex-1 shrink gap-5 items-center h-full text-center rounded-xl basis-12 min-w-[240px] ${
activeTab === tab
? "text-white"
: "bg-slate-200 text-gray-950"
}`}
style={{ cursor: "pointer" }}
onClick={() => onTabChange(tab)}
>
<div
className={`flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 min-w-[240px] max-md:px-5 ${
activeTab === tab
? "text-white bg-red-600"
: "bg-slate-200 text-gray-950"
}`}
>
{tab}
</div>
</div>
))}
<div
className="relative w-[240px] max-w-full"
ref={dropdownRef}
tabIndex={0}
>
<div
className="flex justify-between items-center px-6 py-4 text-sm leading-snug bg-white rounded border border-solid border-stone-300 text-neutral-500 cursor-pointer select-none w-full"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
<span className="truncate">{selectedManufacturer}</span>
<span className="ml-2 flex-shrink-0 flex items-center">
<svg width="20" height="20" fill="none" viewBox="0 0 20 20"><path d="M6 8l4 4 4-4" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</span>
</div>
{isDropdownOpen && (
<ul className="absolute left-0 top-full z-10 bg-white border-x border-b border-stone-300 rounded-b-lg shadow-lg w-full">
{manufacturers.map((option) => (
<li
key={option}
className={`px-6 py-4 cursor-pointer hover:bg-blue-100 ${option === selectedManufacturer ? 'bg-blue-50 font-semibold' : ''}`}
onMouseDown={() => { setSelectedManufacturer(option); setIsDropdownOpen(false); }}
>
{option}
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default ProfileHistoryTabs;

View File

@ -0,0 +1,52 @@
import React from "react";
import { useRouter } from "next/router";
const crumbsMap: Record<string, string> = {
"/profile-orders": "Заказы",
"/profile-history": "История поиска",
"/profile-announcement": "Уведомления",
"/profile-notification": "Оповещения",
"/profile-addresses": "Адреса доставки",
"/profile-gar": "Гараж",
"/profile-set": "Настройки аккаунта",
"/profile-balance": "Баланс",
"/profile-req": "Реквизиты",
"/profile-settlements": "Взаиморасчеты",
"/profile-acts": "Акты сверки",
};
function normalizePath(path: string): string {
return path.replace(/\/+$/, "");
}
export default function ProfileInfo() {
const router = useRouter();
const currentPath: string = normalizePath(router.asPath);
const crumbLabel: string = crumbsMap[currentPath] || "Профиль";
return (
<section className="section-info">
<div className="w-layout-blockcontainer container info w-container">
<div className="w-layout-vflex flex-block-9">
<div className="w-layout-hflex flex-block-7">
<a href="/" className="link-block w-inline-block">
<div>Главная</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block w-inline-block">
<div>Личный кабинет</div>
</a>
<div className="text-block-3"></div>
<a href="#" className="link-block-2 w-inline-block">
<div>{crumbLabel}</div>
</a>
</div>
<div className="w-layout-hflex flex-block-8">
<div className="w-layout-hflex flex-block-10">
<h1 className="heading">{crumbLabel}</h1>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,324 @@
import * as React from "react";
import { useQuery } from '@apollo/client';
import { GET_ORDERS } from '@/lib/graphql';
interface Order {
id: string;
orderNumber: string;
status: 'PENDING' | 'PAID' | 'PROCESSING' | 'SHIPPED' | 'DELIVERED' | 'CANCELED' | 'REFUNDED';
totalAmount: number;
discountAmount: number;
finalAmount: number;
currency: string;
items: Array<{
id: string;
name: string;
article?: string;
brand?: string;
price: number;
quantity: number;
totalPrice: number;
}>;
deliveryAddress?: string;
comment?: string;
createdAt: string;
updatedAt: string;
}
interface ProfileOrdersMainProps {
// Добавьте необходимые пропсы, если они нужны
}
const tabs = [
{ label: "Все", status: null },
{ label: "Текущие", status: ['PENDING', 'PAID', 'PROCESSING', 'SHIPPED'] },
{ label: "Выполненные", status: ['DELIVERED'] },
{ label: "Отмененные", status: ['CANCELED', 'REFUNDED'] },
];
const statusLabels = {
PENDING: 'Ожидает оплаты',
PAID: 'Оплачен',
PROCESSING: 'В обработке',
SHIPPED: 'Отправлен',
DELIVERED: 'Доставлен',
CANCELED: 'Отменен',
REFUNDED: 'Возвращен'
};
const statusColors = {
PENDING: '#F59E0B',
PAID: '#10B981',
PROCESSING: '#3B82F6',
SHIPPED: '#8B5CF6',
DELIVERED: '#10B981',
CANCELED: '#EF4444',
REFUNDED: '#6B7280'
};
const ProfileOrdersMain: React.FC<ProfileOrdersMainProps> = (props) => {
const [activeTab, setActiveTab] = React.useState(0);
const [search, setSearch] = React.useState("");
const [period, setPeriod] = React.useState("Все");
const periodOptions = ["Все", "Сегодня", "Неделя", "Месяц", "Год"];
const [deliveryMethod, setDeliveryMethod] = React.useState("Все");
const deliveryOptions = ["Все", "Самовывоз", "Доставка"];
const [isPeriodOpen, setIsPeriodOpen] = React.useState(false);
const [isDeliveryOpen, setIsDeliveryOpen] = React.useState(false);
const [clientId, setClientId] = React.useState<string | null>(null);
// Получаем ID клиента из localStorage
React.useEffect(() => {
const userData = localStorage.getItem('userData');
if (userData) {
try {
const user = JSON.parse(userData);
setClientId(user.id);
} catch (error) {
console.error('Ошибка парсинга userData:', error);
}
}
}, []);
// Загружаем заказы
const { data, loading, error, refetch } = useQuery(GET_ORDERS, {
variables: {
clientId: clientId?.startsWith('client_') ? clientId.substring(7) : clientId,
limit: 100,
offset: 0
},
skip: !clientId, // Не выполняем запрос пока нет clientId
fetchPolicy: 'cache-and-network'
});
const orders: Order[] = data?.orders?.orders || [];
// Фильтруем заказы по активной вкладке
const filteredOrdersByTab = React.useMemo(() => {
const currentTab = tabs[activeTab];
if (!currentTab.status) {
return orders; // Все заказы
}
return orders.filter(order => currentTab.status!.includes(order.status));
}, [orders, activeTab]);
// Фильтруем по поиску
const filteredOrders = React.useMemo(() => {
if (!search) return filteredOrdersByTab;
const searchLower = search.toLowerCase();
return filteredOrdersByTab.filter(order =>
order.orderNumber.toLowerCase().includes(searchLower) ||
order.items.some(item =>
item.name.toLowerCase().includes(searchLower) ||
item.article?.toLowerCase().includes(searchLower) ||
item.brand?.toLowerCase().includes(searchLower)
)
);
}, [filteredOrdersByTab, search]);
const formatPrice = (price: number, currency = 'RUB') => {
return `${price.toLocaleString('ru-RU')} ${currency === 'RUB' ? '₽' : currency}`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
if (!clientId) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-center py-8">
<p className="text-gray-500">Необходимо авторизоваться для просмотра заказов</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600 mx-auto mb-4"></div>
<p className="text-gray-500">Загрузка заказов...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="text-center py-8">
<p className="text-red-500">Ошибка загрузки заказов: {error.message}</p>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Попробовать снова
</button>
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 w-full whitespace-nowrap max-md:max-w-full">
<div className="flex flex-wrap flex-1 shrink gap-5 self-start text-lg font-medium leading-tight text-center basis-[60px] min-w-[240px] text-gray-950 max-md:max-w-full">
{tabs.map((tab, idx) => (
<div
key={tab.label}
className={`flex flex-1 shrink gap-5 items-center h-full rounded-xl basis-0 ${activeTab === idx ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
style={{ cursor: "pointer" }}
onClick={() => setActiveTab(idx)}
>
<div
className={`flex-1 shrink gap-5 self-stretch px-6 py-3.5 my-auto w-full rounded-xl basis-0 max-md:px-5 ${activeTab === idx ? "bg-red-600 text-white" : "bg-slate-200 text-gray-950"}`}
>
{tab.label}
</div>
</div>
))}
</div>
<div className="flex flex-1 shrink gap-5 items-center px-8 py-3 h-full text-base leading-snug text-gray-400 bg-white rounded-lg basis-0 max-w-[360px] min-w-[240px] max-md:px-5">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск по заказам"
className="flex-1 shrink self-stretch my-auto basis-0 text-ellipsis outline-none bg-transparent text-gray-950 placeholder:text-gray-400"
/>
<img
loading="lazy"
src="https://cdn.builder.io/api/v1/image/assets/TEMP/c08da0aac46dcf126a2a1a0e5832e3b069cd2d94?placeholderIfAbsent=true&apiKey=f5bc5a2dc9b841d0aba1cc6c74a35920"
className="object-contain shrink-0 self-stretch my-auto w-5 rounded-sm aspect-square"
/>
</div>
</div>
<div className="flex overflow-hidden flex-col p-8 mt-5 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">{tabs[activeTab].label}</div>
{filteredOrders.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">
{search ? 'Заказы не найдены' : 'У вас пока нет заказов'}
</div>
{!search && (
<div className="text-gray-500 text-sm">
Оформите первый заказ в нашем каталоге
</div>
)}
</div>
) : (
<div className="space-y-6 mt-5">
{filteredOrders.map((order) => (
<div key={order.id} className="flex flex-col justify-center px-5 py-8 w-full bg-white rounded-2xl border border-gray-200">
<div className="flex flex-col pr-7 pl-5 w-full max-md:pr-5 max-md:max-w-full">
<div className="flex flex-wrap gap-10 justify-between items-center w-full max-md:max-w-full">
<div className="flex gap-5 items-center self-stretch my-auto min-w-[240px]">
<div
className="gap-5 self-stretch px-6 py-3.5 my-auto text-sm font-medium leading-snug text-center text-white whitespace-nowrap rounded-xl max-md:px-5"
style={{ backgroundColor: statusColors[order.status] }}
>
{statusLabels[order.status]}
</div>
<div className="self-stretch my-auto text-xl font-semibold leading-tight text-gray-950">
Заказ {order.orderNumber} от {formatDate(order.createdAt)}
</div>
</div>
</div>
</div>
<div className="flex flex-col mt-5 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center pr-24 pb-2.5 pl-2 w-full text-sm text-gray-400 whitespace-nowrap border-b border-solid border-b-stone-300 max-md:pr-5 max-md:max-w-full">
<div className="gap-1.5 self-stretch my-auto w-9"></div>
<div className="flex gap-1.5 items-center self-stretch my-auto w-[130px]">
<div className="self-stretch my-auto">Производитель</div>
</div>
<div className="gap-1.5 self-stretch my-auto w-[120px]">Артикул</div>
<div className="flex flex-1 shrink gap-1.5 items-center self-stretch my-auto basis-0 min-w-[240px]">
<div className="self-stretch my-auto">Наименование</div>
</div>
<div className="self-stretch my-auto w-[60px]">Кол-во</div>
<div className="self-stretch my-auto text-right w-[90px]">Стоимость</div>
</div>
<div className="flex flex-col mt-1.5 w-full max-md:max-w-full">
{order.items.map((item, index) => (
<div key={item.id} className="flex flex-wrap gap-5 items-center pt-1.5 pr-7 pb-2 pl-2 w-full rounded-lg min-w-[420px] max-md:pr-5 max-md:max-w-full">
<div className="self-stretch my-auto w-9 text-sm leading-4 text-center text-black">
{index + 1}
</div>
<div className="flex flex-wrap flex-1 shrink gap-5 items-center self-stretch my-auto basis-0 min-w-[240px] max-md:max-w-full">
<div className="self-stretch my-auto text-sm font-bold leading-snug text-gray-950 w-[130px]">
{item.brand || '-'}
</div>
<div className="self-stretch my-auto text-sm font-bold leading-snug text-gray-950 w-[120px]">
{item.article || '-'}
</div>
<div className="flex-1 shrink self-stretch my-auto text-sm text-gray-400 basis-0">
{item.name}
</div>
<div className="self-stretch text-sm text-gray-400 w-[60px]">
{item.quantity} шт.
</div>
<div className="flex flex-col justify-center self-stretch my-auto text-right w-[90px]">
<div className="text-sm font-bold leading-snug text-gray-950">
{formatPrice(item.totalPrice, order.currency)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Итоговая сумма */}
<div className="flex justify-end mt-4 pt-4 border-t border-gray-200">
<div className="text-right space-y-1">
<div className="text-sm text-gray-500">
Сумма товаров: {formatPrice(order.totalAmount, order.currency)}
</div>
{order.discountAmount > 0 && (
<div className="text-sm text-gray-500">
Скидка: -{formatPrice(order.discountAmount, order.currency)}
</div>
)}
<div className="text-lg font-bold text-gray-950">
Итого: {formatPrice(order.finalAmount, order.currency)}
</div>
</div>
</div>
{/* Адрес доставки */}
{order.deliveryAddress && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm text-gray-500 mb-1">Адрес доставки:</div>
<div className="text-sm text-gray-950">{order.deliveryAddress}</div>
</div>
)}
{/* Комментарий */}
{order.comment && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="text-sm text-gray-500 mb-1">Комментарий:</div>
<div className="text-sm text-gray-950">{order.comment}</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ProfileOrdersMain;

View File

@ -0,0 +1,103 @@
import React from "react";
interface ProfilePersonalDataProps {
firstName: string;
setFirstName: (v: string) => void;
lastName: string;
setLastName: (v: string) => void;
phone: string;
setPhone: (v: string) => void;
email: string;
setEmail: (v: string) => void;
phoneError: string;
emailError: string;
onSave?: () => void;
}
const ProfilePersonalData: React.FC<ProfilePersonalDataProps> = ({
firstName,
setFirstName,
lastName,
setLastName,
phone,
setPhone,
email,
setEmail,
phoneError,
emailError,
onSave,
}) => (
<div className="flex overflow-hidden flex-col p-8 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950">
Персональные данные
</div>
<div className="flex flex-col mt-8 w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-start w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Имя</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="Имя"
className="w-full bg-transparent outline-none text-gray-600"
value={firstName}
onChange={e => setFirstName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">Фамилия</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="Фамилия"
className="w-full bg-transparent outline-none text-gray-600"
value={lastName}
onChange={e => setLastName(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[240px]">
<div className="text-gray-950">Номер телефона</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] max-md:px-5">
<input
type="text"
placeholder="Телефон"
className={`w-full bg-transparent outline-none text-gray-600 ${phoneError ? 'border-red-500' : ''}`}
value={phone}
onChange={e => setPhone(e.target.value)}
/>
</div>
{phoneError && <div className="text-red-500 text-xs mt-1 ml-2">{phoneError}</div>}
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[240px]">
<div className="text-gray-950">E-mail</div>
<div className="gap-2.5 self-stretch px-6 py-3.5 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[46px] text-neutral-500 max-md:px-5">
<input
type="email"
placeholder="E-mail"
className={`w-full bg-transparent outline-none text-neutral-500 ${emailError ? 'border-red-500' : ''}`}
value={email}
onChange={e => setEmail(e.target.value)}
/>
</div>
{emailError && <div className="text-red-500 text-xs mt-1 ml-2">{emailError}</div>}
</div>
</div>
{onSave && (
<div className="flex justify-end mt-6 max-md:self-start">
<button
onClick={onSave}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
style={{ color: '#fff' }}
>
Сохранить изменения
</button>
</div>
)}
</div>
</div>
);
export default ProfilePersonalData;

View File

@ -0,0 +1,522 @@
import * as React from "react";
import { useState } from "react";
import { useQuery, useMutation } from '@apollo/client';
import { useRouter } from 'next/router';
import {
GET_CLIENT_ME,
CREATE_CLIENT_BANK_DETAILS,
UPDATE_CLIENT_BANK_DETAILS,
DELETE_CLIENT_BANK_DETAILS
} from '@/lib/graphql';
interface BankDetail {
id: string;
name: string;
accountNumber: string;
bankName: string;
bik: string;
correspondentAccount: string;
legalEntityId: string;
legalEntity?: {
id: string;
shortName: string;
inn: string;
};
}
interface LegalEntity {
id: string;
shortName: string;
inn: string;
bankDetails: BankDetail[];
}
interface ClientData {
id: string;
name: string;
legalEntities: LegalEntity[];
}
const ProfileRequisitiesMain = () => {
const router = useRouter();
const [selectedBankDetailId, setSelectedBankDetailId] = useState<string | null>(null);
const [selectedLegalEntityId, setSelectedLegalEntityId] = useState<string | null>(null);
const [accountName, setAccountName] = useState("");
const [accountNumber, setAccountNumber] = useState("");
const [bik, setBik] = useState("");
const [bankName, setBankName] = useState("");
const [corrAccount, setCorrAccount] = useState("");
const [showAddForm, setShowAddForm] = useState(false);
const [editingBankDetail, setEditingBankDetail] = useState<BankDetail | null>(null);
// GraphQL запросы
const { data, loading, error, refetch } = useQuery(GET_CLIENT_ME, {
onCompleted: (data) => {
console.log('Данные клиента загружены:', data);
// Устанавливаем первое юридическое лицо как выбранное по умолчанию
if (data?.clientMe?.legalEntities?.length > 0) {
setSelectedLegalEntityId(data.clientMe.legalEntities[0].id);
// Находим первый банковский счет среди всех юридических лиц
const allBankDetails = data.clientMe.legalEntities.flatMap((le: LegalEntity) => le.bankDetails || []);
if (allBankDetails.length > 0) {
setSelectedBankDetailId(allBankDetails[0].id);
}
}
},
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
}
});
const [createBankDetails] = useMutation(CREATE_CLIENT_BANK_DETAILS, {
onCompleted: () => {
console.log('Банковские реквизиты созданы');
clearForm();
setShowAddForm(false);
refetch();
},
onError: (error) => {
console.error('Ошибка создания банковских реквизитов:', error);
alert('Ошибка создания банковских реквизитов: ' + error.message);
}
});
const [updateBankDetails] = useMutation(UPDATE_CLIENT_BANK_DETAILS, {
onCompleted: () => {
console.log('Банковские реквизиты обновлены');
clearForm();
setShowAddForm(false);
setEditingBankDetail(null);
refetch();
},
onError: (error) => {
console.error('Ошибка обновления банковских реквизитов:', error);
alert('Ошибка обновления банковских реквизитов: ' + error.message);
}
});
const [deleteBankDetails] = useMutation(DELETE_CLIENT_BANK_DETAILS, {
onCompleted: () => {
console.log('Банковские реквизиты удалены');
refetch();
},
onError: (error) => {
console.error('Ошибка удаления банковских реквизитов:', error);
alert('Ошибка удаления банковских реквизитов: ' + error.message);
}
});
const clearForm = () => {
setAccountName("");
setAccountNumber("");
setBik("");
setBankName("");
setCorrAccount("");
};
const handleSave = async () => {
// Валидация
if (!accountName.trim()) {
alert('Введите название счета');
return;
}
if (!accountNumber.trim() || accountNumber.length < 20) {
alert('Введите корректный номер расчетного счета');
return;
}
if (!bik.trim() || bik.length !== 9) {
alert('Введите корректный БИК (9 цифр)');
return;
}
if (!bankName.trim()) {
alert('Введите наименование банка');
return;
}
if (!corrAccount.trim() || corrAccount.length < 20) {
alert('Введите корректный корреспондентский счет');
return;
}
// Определяем legalEntityId для сохранения
let legalEntityIdForSave = selectedLegalEntityId;
// Если юридическое лицо не выбрано, но есть только одно - используем его
if (!legalEntityIdForSave && legalEntities.length === 1) {
legalEntityIdForSave = legalEntities[0].id;
}
if (!legalEntityIdForSave) {
alert('Выберите юридическое лицо');
return;
}
try {
const input = {
name: accountName.trim(),
accountNumber: accountNumber.trim(),
bik: bik.trim(),
bankName: bankName.trim(),
correspondentAccount: corrAccount.trim()
};
if (editingBankDetail) {
// Обновляем существующие реквизиты
await updateBankDetails({
variables: {
id: editingBankDetail.id,
input,
legalEntityId: legalEntityIdForSave
}
});
} else {
// Создаем новые реквизиты
await createBankDetails({
variables: {
legalEntityId: legalEntityIdForSave,
input
}
});
}
} catch (error) {
console.error('Ошибка сохранения:', error);
}
};
const handleEdit = (bankDetail: BankDetail) => {
console.log('Редактирование банковских реквизитов:', bankDetail);
setEditingBankDetail(bankDetail);
setAccountName(bankDetail.name);
setAccountNumber(bankDetail.accountNumber);
setBik(bankDetail.bik);
setBankName(bankDetail.bankName);
setCorrAccount(bankDetail.correspondentAccount);
setSelectedLegalEntityId(bankDetail.legalEntityId);
setShowAddForm(true);
};
const handleDelete = async (bankDetailId: string, bankDetailName: string) => {
if (window.confirm(`Вы уверены, что хотите удалить банковские реквизиты "${bankDetailName}"?`)) {
try {
await deleteBankDetails({
variables: { id: bankDetailId }
});
} catch (error) {
console.error('Ошибка удаления:', error);
}
}
};
const handleCancel = () => {
clearForm();
setShowAddForm(false);
setEditingBankDetail(null);
};
const handleAddNew = () => {
clearForm();
setEditingBankDetail(null);
setShowAddForm(true);
};
if (loading) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex flex-col items-center justify-center p-8 bg-white rounded-2xl">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка данных...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex flex-col items-center justify-center p-8 bg-white rounded-2xl">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm">{error.message}</div>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Повторить
</button>
</div>
</div>
</div>
);
}
const clientData: ClientData | null = data?.clientMe || null;
const legalEntities = clientData?.legalEntities || [];
const selectedLegalEntity = legalEntities.find(le => le.id === selectedLegalEntityId);
const allBankDetails = legalEntities.flatMap(le => le.bankDetails?.filter(bd => bd && bd.id) || []);
if (legalEntities.length === 0) {
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 min-w-[240px] max-md:max-w-full">
<div className="flex overflow-hidden flex-col p-8 w-full bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-3xl font-bold leading-none text-gray-950 max-md:max-w-full">
Банковские реквизиты
</div>
<div className="mt-8 text-gray-600">
У вас пока нет юридических лиц. Создайте юридическое лицо, чтобы добавить банковские реквизиты.
</div>
<div className="flex gap-8 items-start self-start mt-8">
<button
onClick={() => router.push('/profile-set')}
style={{ color: 'fff' }}
className="gap-2.5 self-stretch px-5 py-4 bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white text-base font-medium leading-tight text-center hover:bg-red-700"
>
Создать юридическое лицо
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 w-full">
<div className="flex overflow-hidden flex-col p-8 w-full text-3xl font-bold leading-none bg-white rounded-2xl max-md:px-5 max-md:max-w-full">
<div className="text-gray-950 max-md:max-w-full">
Реквизиты {selectedLegalEntity ? selectedLegalEntity.shortName : 'юридического лица'}
</div>
<div className="flex flex-col mt-8 w-full text-sm leading-snug text-gray-600 max-md:max-w-full">
{allBankDetails.length === 0 ? (
<div className="text-gray-600 py-8 text-center">
У вас пока нет добавленных банковских реквизитов.
</div>
) : (
allBankDetails.map((bankDetail) => (
<div key={bankDetail.id} className="flex flex-col justify-center px-5 py-3 w-full rounded-lg bg-slate-50 max-md:max-w-full mb-2.5">
<div className="flex flex-wrap gap-10 justify-between items-center w-full max-md:max-w-full">
<div className="flex flex-wrap gap-5 items-center self-stretch my-auto min-w-[240px] max-md:max-w-full">
<div className="self-stretch my-auto text-xl font-bold leading-none text-gray-950">
{bankDetail.name}
</div>
<div className="self-stretch my-auto text-gray-600">
р/с {bankDetail.accountNumber}
</div>
<div className="self-stretch my-auto text-gray-600">
{bankDetail.bankName}
</div>
<div className="self-stretch my-auto text-gray-600 text-sm">
БИК: {bankDetail.bik}
</div>
<div className="flex gap-1.5 items-center self-stretch my-auto" role="button" tabIndex={0} aria-label="Юридическое лицо">
<img
src="/images/icon-setting.svg"
alt="ЮЛ"
className="object-contain w-[18px] h-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
{(() => {
const entity = legalEntities.find(le => le.id === bankDetail.legalEntityId);
return entity ? entity.shortName : (bankDetail.legalEntityId ? 'Неизвестное ЮЛ' : 'Не привязан к ЮЛ');
})()}
</div>
</div>
<div className="flex gap-1.5 items-center self-stretch my-auto">
<div
className="relative aspect-[1/1] h-[18px] w-[18px] cursor-pointer"
onClick={() => setSelectedBankDetailId(bankDetail.id)}
>
<div>
<div
dangerouslySetInnerHTML={{
__html: selectedBankDetailId === bankDetail.id
? `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="9" cy="9" r="8.5" stroke="#EC1C24"/><circle cx="9.0001" cy="8.99961" r="5.4" fill="#FF0000"/></svg>`
: `<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="9" cy="9" r="8.5" stroke="#D0D0D0"/></svg>`
}}
/>
</div>
</div>
<div className="text-sm leading-5 text-gray-600">
Основной счет
</div>
</div>
</div>
<div className="flex gap-5 items-center self-stretch pr-2.5 my-auto whitespace-nowrap">
<div
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer hover:text-red-600"
role="button"
tabIndex={0}
aria-label="Редактировать счет"
onClick={() => handleEdit(bankDetail)}
>
<img
src="/images/edit.svg"
alt="Редактировать"
className="object-contain w-[18px] h-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Редактировать
</div>
</div>
<div
className="flex gap-1.5 items-center self-stretch my-auto cursor-pointer hover:text-red-600"
role="button"
tabIndex={0}
aria-label="Удалить счет"
onClick={() => handleDelete(bankDetail.id, bankDetail.name)}
>
<img
src="/images/delete.svg"
alt="Удалить"
className="object-contain w-[18px] h-[18px]"
/>
<div className="self-stretch my-auto text-gray-600">
Удалить
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
{!showAddForm && (
<div
className="gap-2.5 self-stretch px-5 py-4 my-4 bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white text-base font-medium leading-tight text-center w-fit hover:bg-red-700"
onClick={handleAddNew}
>
Добавить реквизиты {selectedLegalEntity ? `для ${selectedLegalEntity.shortName}` : ''}
</div>
)}
{showAddForm && (
<>
<div className="mt-8 text-gray-950">
{editingBankDetail ? 'Редактирование реквизитов' : 'Добавление реквизитов'}
</div>
{/* Выбор юридического лица */}
{legalEntities.length > 1 && (
<div className="flex flex-col mt-4 w-full">
<div className="text-sm text-gray-950 mb-2">
Юридическое лицо
{editingBankDetail && (
<span className="text-xs text-gray-500 ml-2">
(при редактировании можно изменить привязку)
</span>
)}
</div>
<select
value={selectedLegalEntityId || ''}
onChange={(e) => setSelectedLegalEntityId(e.target.value)}
className="gap-2.5 px-6 py-4 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-gray-600 outline-none "
>
<option value="">Выберите юридическое лицо</option>
{legalEntities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.shortName} (ИНН: {entity.inn})
</option>
))}
</select>
</div>
)}
{/* Если юридическое лицо одно, показываем его */}
{legalEntities.length === 1 && (
<div className="flex flex-col mt-4 w-full">
<div className="text-sm text-gray-950 mb-2">Юридическое лицо</div>
<div className="gap-2.5 px-6 py-4 w-full bg-gray-50 rounded border border-solid border-stone-300 min-h-[52px] text-gray-600 flex items-center">
{legalEntities[0].shortName} (ИНН: {legalEntities[0].inn})
</div>
</div>
)}
<div className="flex flex-col mt-8 w-full text-sm leading-snug max-md:max-w-full">
<div className="flex flex-row flex-wrap gap-5 items-start w-full min-h-[78px] max-md:max-w-full">
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">Название счета</div>
<input
type="text"
value={accountName}
onChange={e => setAccountName(e.target.value)}
placeholder="Произвольное название"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full text-gray-600 bg-white rounded border border-solid border-stone-300 min-h-[52px] max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap"> Расчетного счета</div>
<input
type="text"
value={accountNumber}
onChange={e => setAccountNumber(e.target.value)}
placeholder="20 цифр"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink whitespace-nowrap basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">БИК</div>
<input
type="text"
value={bik}
onChange={e => setBik(e.target.value)}
placeholder="9 цифр"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">Наименование банка</div>
<input
type="text"
value={bankName}
onChange={e => setBankName(e.target.value)}
placeholder="Наименование банка"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
<div className="flex flex-col flex-1 shrink basis-0 min-w-[210px]">
<div className="text-gray-950 whitespace-nowrap">Корреспондентский счет</div>
<input
type="text"
value={corrAccount}
onChange={e => setCorrAccount(e.target.value)}
placeholder="20 цифр"
className="gap-2.5 self-stretch px-6 py-4 mt-1.5 w-full bg-white rounded border border-solid border-stone-300 min-h-[52px] text-neutral-500 max-md:px-5 outline-none "
/>
</div>
</div>
</div>
<div className="flex gap-8 items-start self-start mt-8 text-base font-medium leading-tight text-center whitespace-nowrap">
<div
className="gap-2.5 self-stretch px-5 py-4 my-auto bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white hover:bg-red-700"
onClick={handleSave}
>
{editingBankDetail ? 'Сохранить' : 'Добавить'}
</div>
<div
className="gap-2.5 self-stretch px-5 py-4 my-auto rounded-xl border border-red-600 min-h-[50px] cursor-pointer bg-white text-gray-950 hover:bg-gray-50"
onClick={handleCancel}
>
Отменить
</div>
</div>
</>
)}
</div>
<div className="flex overflow-hidden gap-10 items-center px-5 py-4 mt-5 w-full text-lg font-medium leading-tight text-center text-black bg-white rounded-2xl max-md:max-w-full">
<div
className="gap-2.5 self-stretch px-10 py-6 my-auto text-black rounded-xl border cursor-pointer border-red-600 border-solid min-w-[240px] max-md:px-5 hover:bg-gray-50"
onClick={() => router.push('/profile-set')}
>
Управление юридическими лицами
</div>
</div>
</div>
);
}
export default ProfileRequisitiesMain;

View File

@ -0,0 +1,18 @@
import React from "react";
interface ProfileSettingsActionsBlockProps {
onAddLegalEntity: () => void;
}
const ProfileSettingsActionsBlock: React.FC<ProfileSettingsActionsBlockProps> = ({ onAddLegalEntity }) => (
<div className="flex overflow-hidden flex-wrap gap-10 justify-between items-center px-5 py-4 mt-5 w-full text-base font-medium leading-tight text-center bg-white rounded-2xl max-md:max-w-full">
<div className="gap-2.5 self-stretch px-5 py-4 my-auto bg-red-600 rounded-xl min-h-[50px] cursor-pointer text-white">
Сохранить изменения
</div>
<div className="gap-2.5 self-stretch px-5 py-4 my-auto rounded-xl border border-red-600 min-h-[50px] min-w-[240px] cursor-pointer bg-white text-gray-950" onClick={onAddLegalEntity}>
Добавить юридическое лицо
</div>
</div>
);
export default ProfileSettingsActionsBlock;

View File

@ -0,0 +1,304 @@
import * as React from "react";
import { useQuery, useMutation } from '@apollo/client';
import { GET_CLIENT_ME, UPDATE_CLIENT_PERSONAL_DATA } from '@/lib/graphql';
import ProfilePersonalData from "./ProfilePersonalData";
import LegalEntityListBlock from "./LegalEntityListBlock";
import LegalEntityFormBlock from "./LegalEntityFormBlock";
import ProfileSettingsActionsBlock from "./ProfileSettingsActionsBlock";
interface ClientData {
id: string;
name: string;
email: string;
phone: string;
emailNotifications: boolean;
smsNotifications: boolean;
pushNotifications: boolean;
legalEntities: Array<{
id: string;
shortName: string;
fullName?: string;
form?: string;
legalAddress?: string;
actualAddress?: string;
taxSystem?: string;
responsiblePhone?: string;
responsiblePosition?: string;
responsibleName?: string;
accountant?: string;
signatory?: string;
registrationReasonCode?: string;
ogrn?: string;
inn: string;
vatPercent: number;
bankDetails: Array<{
id: string;
name: string;
accountNumber: string;
bankName: string;
bik: string;
correspondentAccount: string;
}>;
}>;
}
const ProfileSettingsMain = () => {
const [form, setForm] = React.useState("Выбрать");
const [isFormOpen, setIsFormOpen] = React.useState(false);
const formOptions = ["ООО", "ИП", "АО", "ПАО", "Другое"];
const [taxSystem, setTaxSystem] = React.useState("Выбрать");
const [isTaxSystemOpen, setIsTaxSystemOpen] = React.useState(false);
const taxSystemOptions = ["ОСНО", "УСН", "ЕНВД", "ПСН"];
const [nds, setNds] = React.useState("Выбрать");
const [isNdsOpen, setIsNdsOpen] = React.useState(false);
const ndsOptions = ["Без НДС", "НДС 10%", "НДС 20%", "Другое"];
const [showLegalEntityForm, setShowLegalEntityForm] = React.useState(false);
const [editingEntity, setEditingEntity] = React.useState<ClientData['legalEntities'][0] | null>(null);
// Состояние для формы юридического лица
const [inn, setInn] = React.useState("");
const [ogrn, setOgrn] = React.useState("");
const [kpp, setKpp] = React.useState("");
const [jurAddress, setJurAddress] = React.useState("");
const [shortName, setShortName] = React.useState("");
const [fullName, setFullName] = React.useState("");
const [factAddress, setFactAddress] = React.useState("");
const [ndsPercent, setNdsPercent] = React.useState("");
const [accountant, setAccountant] = React.useState("");
const [responsible, setResponsible] = React.useState("");
const [responsiblePosition, setResponsiblePosition] = React.useState("");
const [responsiblePhone, setResponsiblePhone] = React.useState("");
const [signatory, setSignatory] = React.useState("");
// Состояние для личных данных
const [firstName, setFirstName] = React.useState("");
const [lastName, setLastName] = React.useState("");
const [phone, setPhone] = React.useState("");
const [email, setEmail] = React.useState("");
const [phoneError, setPhoneError] = React.useState("");
const [emailError, setEmailError] = React.useState("");
// GraphQL запросы
const { data, loading, error, refetch } = useQuery(GET_CLIENT_ME, {
onCompleted: (data) => {
console.log('Данные клиента загружены:', data);
if (data?.clientMe) {
const client = data.clientMe;
// Разделяем имя на имя и фамилию
const nameParts = client.name?.split(' ') || ['', ''];
setFirstName(nameParts[0] || '');
setLastName(nameParts.slice(1).join(' ') || '');
setPhone(client.phone || '');
setEmail(client.email || '');
}
},
onError: (error) => {
console.error('Ошибка загрузки данных клиента:', error);
}
});
const [updatePersonalData] = useMutation(UPDATE_CLIENT_PERSONAL_DATA, {
onCompleted: () => {
console.log('Личные данные обновлены');
refetch();
},
onError: (error) => {
console.error('Ошибка обновления личных данных:', error);
}
});
const handleSavePersonalData = async () => {
try {
// Валидация
setPhoneError('');
setEmailError('');
if (!phone || phone.length < 10) {
setPhoneError('Введите корректный номер телефона');
return;
}
if (!email || !email.includes('@')) {
setEmailError('Введите корректный email');
return;
}
await updatePersonalData({
variables: {
input: {
type: 'INDIVIDUAL',
name: `${firstName} ${lastName}`.trim(),
phone,
email,
emailNotifications: false
}
}
});
alert('Личные данные сохранены!');
} catch (error) {
console.error('Ошибка сохранения:', error);
alert('Ошибка сохранения данных');
}
};
const handleEditEntity = (entity: ClientData['legalEntities'][0]) => {
setEditingEntity(entity);
setShowLegalEntityForm(true);
// Заполняем форму данными редактируемого юридического лица
setShortName(entity.shortName);
setFullName(entity.fullName || '');
setForm(entity.form || 'ООО');
setJurAddress(entity.legalAddress || '');
setFactAddress(entity.actualAddress || '');
setInn(entity.inn);
setOgrn(entity.ogrn || '');
setTaxSystem(entity.taxSystem || 'УСН');
setNdsPercent(entity.vatPercent.toString());
setAccountant(entity.accountant || '');
setResponsible(entity.responsibleName || '');
setResponsiblePosition(entity.responsiblePosition || '');
setResponsiblePhone(entity.responsiblePhone || '');
setSignatory(entity.signatory || '');
};
const handleAddEntity = () => {
setEditingEntity(null);
setShowLegalEntityForm(true);
// Очищаем форму для нового юридического лица
setShortName('');
setFullName('');
setForm('ООО');
setJurAddress('');
setFactAddress('');
setInn('');
setOgrn('');
setTaxSystem('УСН');
setNdsPercent('20');
setAccountant('');
setResponsible('');
setResponsiblePosition('');
setResponsiblePhone('');
setSignatory('');
};
if (loading) {
return (
<div className="flex flex-col justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-600"></div>
<div className="mt-4 text-gray-600">Загрузка данных...</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col justify-center items-center p-8">
<div className="text-red-600 text-center">
<div className="text-lg font-semibold mb-2">Ошибка загрузки данных</div>
<div className="text-sm">{error.message}</div>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Повторить
</button>
</div>
</div>
);
}
const clientData: ClientData | null = data?.clientMe || null;
return (
<div className="flex flex-col flex-1 shrink justify-center basis-0 w-full max-md:max-w-full">
<ProfilePersonalData
firstName={firstName}
setFirstName={setFirstName}
lastName={lastName}
setLastName={setLastName}
phone={phone}
setPhone={setPhone}
email={email}
setEmail={setEmail}
phoneError={phoneError}
emailError={emailError}
onSave={handleSavePersonalData}
/>
<LegalEntityListBlock
legalEntities={clientData?.legalEntities || []}
onRefetch={refetch}
onEdit={handleEditEntity}
/>
{showLegalEntityForm && (
<LegalEntityFormBlock
inn={inn}
setInn={setInn}
form={form}
setForm={setForm}
isFormOpen={isFormOpen}
setIsFormOpen={setIsFormOpen}
formOptions={formOptions}
ogrn={ogrn}
setOgrn={setOgrn}
kpp={kpp}
setKpp={setKpp}
jurAddress={jurAddress}
setJurAddress={setJurAddress}
shortName={shortName}
setShortName={setShortName}
fullName={fullName}
setFullName={setFullName}
factAddress={factAddress}
setFactAddress={setFactAddress}
taxSystem={taxSystem}
setTaxSystem={setTaxSystem}
isTaxSystemOpen={isTaxSystemOpen}
setIsTaxSystemOpen={setIsTaxSystemOpen}
taxSystemOptions={taxSystemOptions}
nds={nds}
setNds={setNds}
isNdsOpen={isNdsOpen}
setIsNdsOpen={setIsNdsOpen}
ndsOptions={ndsOptions}
ndsPercent={ndsPercent}
setNdsPercent={setNdsPercent}
accountant={accountant}
setAccountant={setAccountant}
responsible={responsible}
setResponsible={setResponsible}
responsiblePosition={responsiblePosition}
setResponsiblePosition={setResponsiblePosition}
responsiblePhone={responsiblePhone}
setResponsiblePhone={setResponsiblePhone}
signatory={signatory}
setSignatory={setSignatory}
editingEntity={editingEntity}
onAdd={() => {
setShowLegalEntityForm(false);
setEditingEntity(null);
refetch(); // Обновляем данные после добавления/редактирования
}}
onCancel={() => {
setShowLegalEntityForm(false);
setEditingEntity(null);
}}
/>
)}
<ProfileSettingsActionsBlock onAddLegalEntity={handleAddEntity} />
</div>
);
}
export default ProfileSettingsMain;

View File

@ -0,0 +1,20 @@
import React from "react";
interface SearchInputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
}
const SearchInput: React.FC<SearchInputProps> = ({ value, onChange, placeholder = "Поиск" }) => (
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
className="w-full bg-transparent outline-none text-gray-400 text-base"
style={{ border: "none", padding: 0, margin: 0 }}
/>
);
export default SearchInput;