feat(marketplace): канон оферт — редактирование изображений, republish, sticky-форма
«Мои предложения»: info-карточка .banner с persist-скрытием, скелетоны вместо лоудеров, канон-вкладки с deep-link ?status=, кнопка «Создать» в правом верхнем углу через teleport, republish для WITHDRAWN. Форма создания/редактирования: единый заголовок (без дублей), один .banner, sticky нижний бар «Отменить/Далее/ Сохранить» фикс-высоты, форматирование цены при префилле. REJECTED правится и переотправляется через форму с показом причины отклонения. Изображения: единая галерея — добавление (не замена), удаление и смена обложки у любой картинки с самого начала. Backend отдаёт/принимает bucket_key (сохранить существующее) либо base64 (новое); чужой ключ отклоняется. Signed-URL чтения стабилизирован в окне 300с — не мигает при polling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8261,6 +8261,11 @@ type MarketplaceOffer {
|
||||
}
|
||||
|
||||
type MarketplaceOfferImage {
|
||||
"""
|
||||
Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов).
|
||||
"""
|
||||
bucket_key: String!
|
||||
|
||||
"""Является ли изображение обложкой карточки."""
|
||||
is_cover: Boolean!
|
||||
|
||||
@@ -8275,11 +8280,14 @@ type MarketplaceOfferImage {
|
||||
}
|
||||
|
||||
input MarketplaceOfferImageUploadInput {
|
||||
"""Содержимое изображения, закодированное в base64."""
|
||||
base64: String!
|
||||
"""Содержимое нового изображения в base64."""
|
||||
base64: String
|
||||
|
||||
"""MIME-тип изображения (image/jpeg, image/png либо image/webp)."""
|
||||
mime_type: String!
|
||||
"""Ключ уже сохранённого изображения — сохранить его в наборе."""
|
||||
bucket_key: String
|
||||
|
||||
"""MIME-тип нового изображения (image/jpeg, image/png либо image/webp)."""
|
||||
mime_type: String
|
||||
}
|
||||
|
||||
type MarketplaceOfferPaginationResult {
|
||||
@@ -8650,6 +8658,10 @@ input MarketplaceRemoveFromWhitelistInput {
|
||||
member_account: String!
|
||||
}
|
||||
|
||||
input MarketplaceRepublishOfferInput {
|
||||
id: String!
|
||||
}
|
||||
|
||||
type MarketplaceRequest {
|
||||
"""Артикул товара"""
|
||||
articleNumber: String!
|
||||
@@ -10873,6 +10885,11 @@ type Mutation {
|
||||
"""
|
||||
marketplaceReplaceAvailableItems(input: ReplaceAvailableItemsInput!): [MarketplaceAvailableCategory!]!
|
||||
|
||||
"""
|
||||
Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION)
|
||||
"""
|
||||
marketplaceRepublishOffer(input: MarketplaceRepublishOfferInput!): MarketplaceOffer!
|
||||
|
||||
"""
|
||||
Повторно запускает геокодинг адреса ПВЗ.
|
||||
|
||||
|
||||
+23
-4
@@ -25,15 +25,27 @@ const UNITS = ['piece', 'kg', 'liter', 'pack'] as const;
|
||||
|
||||
@InputType('MarketplaceOfferImageUploadInput')
|
||||
export class MarketplaceOfferImageUploadInputDTO {
|
||||
@Field({ description: 'Содержимое изображения, закодированное в base64.' })
|
||||
// Элемент набора изображений — ЛИБО новый файл (base64 + mime_type), ЛИБО
|
||||
// ссылка на уже сохранённое изображение по bucket_key (чтобы сохранить его
|
||||
// при пересборке набора). Порядок в массиве = порядок показа, первый —
|
||||
// обложка. Согласованность (ровно одно из двух) проверяется в сервисе.
|
||||
@Field({ nullable: true, description: 'Содержимое нового изображения в base64.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
public readonly base64!: string;
|
||||
public readonly base64?: string;
|
||||
|
||||
@Field({ description: 'MIME-тип изображения (image/jpeg, image/png либо image/webp).' })
|
||||
@Field({ nullable: true, description: 'MIME-тип нового изображения (image/jpeg, image/png либо image/webp).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
public readonly mime_type!: string;
|
||||
public readonly mime_type?: string;
|
||||
|
||||
@Field({ nullable: true, description: 'Ключ уже сохранённого изображения — сохранить его в наборе.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
public readonly bucket_key?: string;
|
||||
}
|
||||
|
||||
@InputType('MarketplaceCreateOfferInput')
|
||||
@@ -248,5 +260,12 @@ export class MarketplaceWithdrawOfferInputDTO {
|
||||
public id!: string;
|
||||
}
|
||||
|
||||
@InputType('MarketplaceRepublishOfferInput')
|
||||
export class MarketplaceRepublishOfferInputDTO {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
public id!: string;
|
||||
}
|
||||
|
||||
@InputType('MarketplaceListMyOffersInput')
|
||||
export class MarketplaceListMyOffersInputDTO extends PaginationInputDTO {}
|
||||
|
||||
+8
@@ -13,6 +13,14 @@ export class MarketplaceOfferImageDTO {
|
||||
})
|
||||
public readonly url!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description:
|
||||
'Ключ объекта в хранилище — стабильный идентификатор изображения. ' +
|
||||
'Передаётся обратно при редактировании, чтобы сохранить уже загруженное ' +
|
||||
'изображение (в отличие от base64 для новых файлов).',
|
||||
})
|
||||
public readonly bucket_key!: string;
|
||||
|
||||
@Field(() => String, { description: 'MIME-тип изображения.' })
|
||||
public readonly mime_type!: string;
|
||||
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export class MarketplaceOfferFieldsResolver {
|
||||
const url = await this.imagesService.getReadUrl(rec.bucket_key);
|
||||
return new MarketplaceOfferImageDTO({
|
||||
url,
|
||||
bucket_key: rec.bucket_key,
|
||||
mime_type: rec.mime_type,
|
||||
sort_order: index,
|
||||
is_cover: index === 0,
|
||||
|
||||
+15
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
MarketplaceCreateOfferInputDTO,
|
||||
MarketplaceListMyOffersInputDTO,
|
||||
MarketplaceRepublishOfferInputDTO,
|
||||
MarketplaceUpdateOfferInputDTO,
|
||||
MarketplaceWithdrawOfferInputDTO,
|
||||
} from '../dto/marketplace-offer-input.dto';
|
||||
@@ -135,6 +136,20 @@ export class MarketplaceOfferResolver {
|
||||
return toDTO(offer);
|
||||
}
|
||||
|
||||
@Mutation(() => MarketplaceOfferDTO, {
|
||||
name: 'marketplaceRepublishOffer',
|
||||
description: 'Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
|
||||
@RequireMarketplaceAccess('Offer', 'update:own')
|
||||
async marketplaceRepublishOffer(
|
||||
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
|
||||
@Args('input') input: MarketplaceRepublishOfferInputDTO
|
||||
): Promise<MarketplaceOfferDTO> {
|
||||
const offer = await this.offerService.republish(input.id, member.username);
|
||||
return toDTO(offer);
|
||||
}
|
||||
|
||||
@Query(() => MarketplaceOfferPaginationResultDTO, {
|
||||
name: 'marketplaceListMyOffers',
|
||||
description: 'Список собственных Offer\'ов поставщика (любой статус)',
|
||||
|
||||
+171
-38
@@ -52,8 +52,11 @@ export const MARKETPLACE_OFFER_SERVICE = Symbol('MARKETPLACE_OFFER_SERVICE');
|
||||
* `MarketplaceOfferImage` (ключ bucket'а + sha256) до записи в БД.
|
||||
*/
|
||||
export interface MarketplaceOfferImageUpload {
|
||||
base64: string;
|
||||
mime_type: string;
|
||||
/** Новый файл: содержимое в base64. Взаимоисключимо с bucket_key. */
|
||||
base64?: string;
|
||||
mime_type?: string;
|
||||
/** Уже сохранённое изображение: сохранить его в наборе по ключу хранилища. */
|
||||
bucket_key?: string;
|
||||
}
|
||||
|
||||
export interface OfferCreateRequest {
|
||||
@@ -84,14 +87,17 @@ export interface OfferCreateRequest {
|
||||
*
|
||||
* Lifecycle (AC):
|
||||
* create → PENDING_MODERATION (rate-limit 10/час на supplier).
|
||||
* edit → если ACTIVE/PENDING_MODERATION — поля обновляются, status
|
||||
* сбрасывается в PENDING_MODERATION; REJECTED edit → 403
|
||||
* (поставщик должен создать новый offer, AC формулировка
|
||||
* «создаст новый PENDING_MODERATION»); WITHDRAWN edit → 403.
|
||||
* edit → ACTIVE/PENDING_MODERATION — поля обновляются (значимый контент
|
||||
* сбрасывает status в PENDING_MODERATION); REJECTED — правка
|
||||
* устраняет причину отклонения и всегда уходит на повторную
|
||||
* модерацию (не пересоздаётся); WITHDRAWN edit → 403 (сначала
|
||||
* republish).
|
||||
* withdraw→ если ACTIVE/PENDING_MODERATION — status=WITHDRAWN;
|
||||
* блокируется при наличии незакрытых Order'ов с понятным
|
||||
* сообщением (заглушка `hasActiveOrders` — реализуется при
|
||||
* merge Story 4.x, в MVP всегда false).
|
||||
* republish→ WITHDRAWN → PENDING_MODERATION. Снятие не удаляет данные, поэтому
|
||||
* вернуть предложение можно без пересоздания — снова на модерацию.
|
||||
*
|
||||
* Owner-проверка (`supplier_account == offer.supplier_account`) на уровне
|
||||
* сервиса — guard даёт capability, а не ownership.
|
||||
@@ -238,43 +244,72 @@ export class MarketplaceOfferService {
|
||||
});
|
||||
}
|
||||
|
||||
// Поле-зависимая модерация. Операционные поля (остаток, цена) поставщик
|
||||
// меняет часто и без участия председателя — их правка НЕ снимает оффер с
|
||||
// публикации и не шлёт на повторную модерацию. Любое же изменение
|
||||
// «модерационно-значимого» контента (название, описание, категория,
|
||||
// фото, единица измерения, условия цикла, гарантия, штрихкод) — то, что
|
||||
// председатель видит и проверяет, — сбрасывает статус в PENDING_MODERATION.
|
||||
// requireOwnedEditable пропускает сюда ACTIVE, PENDING_MODERATION и
|
||||
// REJECTED. Для REJECTED любая правка — это исправление причины отклонения,
|
||||
// поэтому она всегда уходит на повторную модерацию (даже если тронули
|
||||
// только цену/остаток): оффер сейчас невидим в каталоге и должен снова
|
||||
// пройти проверку, чтобы опубликоваться.
|
||||
const NON_MODERATED_FIELDS: ReadonlyArray<keyof OfferUpdateInput> = [
|
||||
'price_per_unit',
|
||||
'quantity_available',
|
||||
'unlimited_flag',
|
||||
];
|
||||
const touchedKeys = (Object.keys(patch) as Array<keyof OfferUpdateInput>).filter(
|
||||
(k) => patch[k] !== undefined,
|
||||
);
|
||||
const moderationSignificantChange =
|
||||
offer.status === 'REJECTED' ||
|
||||
images !== undefined ||
|
||||
touchedKeys.some((k) => !NON_MODERATED_FIELDS.includes(k));
|
||||
|
||||
const normalizedPatch: OfferUpdateInput & {
|
||||
status: MarketplaceOfferStatus;
|
||||
approved_by: string | null;
|
||||
approved_at: Date | null;
|
||||
rejected_by: string | null;
|
||||
rejected_at: Date | null;
|
||||
reject_reason: string | null;
|
||||
} = {
|
||||
...patch,
|
||||
status: 'PENDING_MODERATION',
|
||||
// edit повторно отправляет оффер на модерацию — поля прошлых решений
|
||||
// (approve/reject) не должны утечь в UI как «уже одобрен» / «отклонён
|
||||
// с прошлой причиной».
|
||||
approved_by: null,
|
||||
approved_at: null,
|
||||
rejected_by: null,
|
||||
rejected_at: null,
|
||||
reject_reason: null,
|
||||
};
|
||||
status?: MarketplaceOfferStatus;
|
||||
approved_by?: string | null;
|
||||
approved_at?: Date | null;
|
||||
rejected_by?: string | null;
|
||||
rejected_at?: Date | null;
|
||||
reject_reason?: string | null;
|
||||
} = { ...patch };
|
||||
|
||||
if (moderationSignificantChange) {
|
||||
// edit контента повторно отправляет оффер на модерацию — поля прошлых
|
||||
// решений (approve/reject) не должны утечь в UI как «уже одобрен» /
|
||||
// «отклонён с прошлой причиной».
|
||||
normalizedPatch.status = 'PENDING_MODERATION';
|
||||
normalizedPatch.approved_by = null;
|
||||
normalizedPatch.approved_at = null;
|
||||
normalizedPatch.rejected_by = null;
|
||||
normalizedPatch.rejected_at = null;
|
||||
normalizedPatch.reject_reason = null;
|
||||
}
|
||||
|
||||
if (patch.unlimited_flag === true) {
|
||||
normalizedPatch.quantity_available = 0;
|
||||
}
|
||||
|
||||
// Изображения переданы → полностью заменяем набор. Старые объекты
|
||||
// bucket'а не удаляем (могут шариться по content-hash; orphan-cleanup —
|
||||
// вне MVP). Новые грузим до записи; при провале записи — чистим их.
|
||||
let uploaded: MarketplaceOfferImage[] | undefined;
|
||||
// Изображения переданы → пересобираем набор: элементы с bucket_key —
|
||||
// сохраняем как есть (пользователь оставил уже загруженное), элементы с
|
||||
// base64 — грузим как новые. Порядок = порядок показа, первый = обложка.
|
||||
// Удалённые (не попавшие в набор) объекты bucket'а не подчищаем
|
||||
// (orphan-cleanup — вне MVP). При провале записи чистим ТОЛЬКО что
|
||||
// загруженные — сохранённые существующие трогать нельзя.
|
||||
let newlyUploaded: MarketplaceOfferImage[] = [];
|
||||
if (images !== undefined) {
|
||||
uploaded = await this.uploadImages(offer.coopname, supplier_account, images);
|
||||
normalizedPatch.images = uploaded;
|
||||
const resolved = await this.resolveImagesForUpdate(offer, supplier_account, images);
|
||||
newlyUploaded = resolved.newlyUploaded;
|
||||
normalizedPatch.images = resolved.images;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.repo.applyUpdate(offer.id, normalizedPatch);
|
||||
} catch (e) {
|
||||
if (uploaded) await this.cleanupImages(uploaded);
|
||||
if (newlyUploaded.length) await this.cleanupImages(newlyUploaded);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -291,6 +326,30 @@ export class MarketplaceOfferService {
|
||||
return this.repo.applyUpdate(offer.id, { status: 'WITHDRAWN' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Вернуть ранее снятое предложение на публикацию: WITHDRAWN →
|
||||
* PENDING_MODERATION. Снятие не удаляет данные оферты — все поля и
|
||||
* изображения на месте, поэтому пересоздавать ничего не нужно, достаточно
|
||||
* снова отправить на модерацию. Доступно только владельцу и только для
|
||||
* снятого предложения (REJECTED не возвращаем: его отклонил модератор по
|
||||
* причине — нужна правка, а не повторная отправка тех же данных).
|
||||
*/
|
||||
async republish(id: string, supplier_account: string): Promise<MarketplaceOfferDomainEntity> {
|
||||
const offer = await this.repo.findById(id);
|
||||
if (!offer) {
|
||||
throw new NotFoundException('Предложение не найдено.');
|
||||
}
|
||||
if (offer.supplier_account !== supplier_account) {
|
||||
throw new ForbiddenException('Можно изменять только свои предложения.');
|
||||
}
|
||||
if (offer.status !== 'WITHDRAWN') {
|
||||
throw new ForbiddenException(
|
||||
'Вернуть на публикацию можно только снятое предложение.'
|
||||
);
|
||||
}
|
||||
return this.repo.applyUpdate(offer.id, { status: 'PENDING_MODERATION' });
|
||||
}
|
||||
|
||||
async listMine(
|
||||
coopname: string,
|
||||
supplier_account: string,
|
||||
@@ -483,12 +542,25 @@ export class MarketplaceOfferService {
|
||||
if (offer.supplier_account !== supplier_account) {
|
||||
throw new ForbiddenException('Можно изменять только свои предложения.');
|
||||
}
|
||||
if (offer.status === 'WITHDRAWN' || offer.status === 'REJECTED') {
|
||||
const action = op[0] === 'withdraw' ? 'снять' : 'отредактировать';
|
||||
const statusLabel = offer.status === 'WITHDRAWN' ? 'снято' : 'отклонено';
|
||||
throw new ForbiddenException(
|
||||
`Нельзя ${action} предложение, которое уже ${statusLabel}.`
|
||||
);
|
||||
if (op[0] === 'withdraw') {
|
||||
// Снять можно только опубликованное/ожидающее. Уже снятое или
|
||||
// отклонённое снимать нечего.
|
||||
if (offer.status === 'WITHDRAWN' || offer.status === 'REJECTED') {
|
||||
const statusLabel = offer.status === 'WITHDRAWN' ? 'снято' : 'отклонено';
|
||||
throw new ForbiddenException(
|
||||
`Нельзя снять предложение, которое уже ${statusLabel}.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Редактировать можно ACTIVE / PENDING_MODERATION / REJECTED: отклонённое
|
||||
// правится для устранения причины и переотправки на модерацию (не
|
||||
// пересоздаётся заново). Снятое сначала возвращают на публикацию
|
||||
// (republish), затем редактируют.
|
||||
if (offer.status === 'WITHDRAWN') {
|
||||
throw new ForbiddenException(
|
||||
'Нельзя отредактировать снятое предложение. Сначала верните его на публикацию.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return offer;
|
||||
}
|
||||
@@ -517,6 +589,67 @@ export class MarketplaceOfferService {
|
||||
* (индекс 0 = обложка). При провале загрузки одного из файлов — чистит уже
|
||||
* загруженные, чтобы не плодить сирот, и пробрасывает понятную ошибку.
|
||||
*/
|
||||
/**
|
||||
* Пересборка набора изображений при редактировании. Каждый элемент — либо
|
||||
* ссылка на уже сохранённое изображение (bucket_key — оставляем как есть),
|
||||
* либо новый файл (base64 — грузим). Возвращает финальный упорядоченный
|
||||
* набор и отдельно перечень только что загруженных (для отката при провале
|
||||
* записи Offer'а). Чужой/неизвестный bucket_key отклоняем — сослаться можно
|
||||
* только на изображение текущего предложения.
|
||||
*/
|
||||
private async resolveImagesForUpdate(
|
||||
offer: MarketplaceOfferDomainEntity,
|
||||
supplier_account: string,
|
||||
raw: MarketplaceOfferImageUpload[]
|
||||
): Promise<{ images: MarketplaceOfferImage[]; newlyUploaded: MarketplaceOfferImage[] }> {
|
||||
if (raw.length > MARKETPLACE_OFFER_MAX_IMAGES) {
|
||||
throw new BadRequestException(
|
||||
`Слишком много изображений: максимум ${MARKETPLACE_OFFER_MAX_IMAGES} на одно предложение.`
|
||||
);
|
||||
}
|
||||
const existingByKey = new Map(
|
||||
(offer.images ?? []).map((img) => [img.bucket_key, img])
|
||||
);
|
||||
const images: MarketplaceOfferImage[] = [];
|
||||
const newlyUploaded: MarketplaceOfferImage[] = [];
|
||||
try {
|
||||
for (const item of raw) {
|
||||
if (item.bucket_key) {
|
||||
const kept = existingByKey.get(item.bucket_key);
|
||||
if (!kept) {
|
||||
throw new BadRequestException(
|
||||
'Неизвестное изображение: сослаться можно только на собственные изображения этого предложения.'
|
||||
);
|
||||
}
|
||||
images.push(kept);
|
||||
continue;
|
||||
}
|
||||
if (!item.base64) {
|
||||
throw new BadRequestException('Пустое изображение: отсутствует содержимое файла.');
|
||||
}
|
||||
const bytes = Buffer.from(item.base64, 'base64');
|
||||
if (bytes.length === 0) {
|
||||
throw new BadRequestException('Не удалось декодировать изображение (пустые данные).');
|
||||
}
|
||||
const image = await this.imagesService.putImage({
|
||||
bytes,
|
||||
contentType: item.mime_type as string,
|
||||
coopname: offer.coopname,
|
||||
ownerAccount: supplier_account,
|
||||
});
|
||||
newlyUploaded.push(image);
|
||||
images.push(image);
|
||||
}
|
||||
return { images, newlyUploaded };
|
||||
} catch (e) {
|
||||
await this.cleanupImages(newlyUploaded);
|
||||
if (e instanceof BadRequestException) throw e;
|
||||
throw new BadRequestException(
|
||||
`Не удалось загрузить изображение: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadImages(
|
||||
coopname: string,
|
||||
supplier_account: string,
|
||||
@@ -532,7 +665,7 @@ export class MarketplaceOfferService {
|
||||
const result: MarketplaceOfferImage[] = [];
|
||||
try {
|
||||
for (const file of raw) {
|
||||
if (!file.base64) {
|
||||
if (!file.base64 || !file.mime_type) {
|
||||
throw new BadRequestException('Пустое изображение: отсутствует содержимое файла.');
|
||||
}
|
||||
const bytes = Buffer.from(file.base64, 'base64');
|
||||
|
||||
+16
-3
@@ -276,8 +276,10 @@ describe('MinioFileStorageAdapter — getReadUrl', () => {
|
||||
const exp = Number(parsed.searchParams.get('exp'));
|
||||
const sig = parsed.searchParams.get('sig')!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// exp округляется вверх к границе окна стабилизации (300с): не истёк и не
|
||||
// дальше ttl+окно. Точную верхнюю границу не фиксируем (зависит от фазы окна).
|
||||
expect(exp).toBeGreaterThan(now);
|
||||
expect(exp).toBeLessThanOrEqual(now + 61);
|
||||
expect(exp - now).toBeLessThanOrEqual(60 + 300 + 2);
|
||||
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
const ok = verifyReadUrl({
|
||||
@@ -296,8 +298,19 @@ describe('MinioFileStorageAdapter — getReadUrl', () => {
|
||||
const url = await bucket.getReadUrl('x.jpg');
|
||||
const exp = Number(new URL(url).searchParams.get('exp'));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(exp - now).toBeGreaterThanOrEqual(28);
|
||||
expect(exp - now).toBeLessThanOrEqual(31);
|
||||
// ttl=30 + округление к окну стабилизации (300с).
|
||||
expect(exp - now).toBeGreaterThanOrEqual(25);
|
||||
expect(exp - now).toBeLessThanOrEqual(30 + 300 + 2);
|
||||
});
|
||||
|
||||
it('стабилизирует URL: повторные вызовы для одного ключа в окне дают идентичный URL', async () => {
|
||||
// Регрессия на «мигающую» картинку: подписанный URL не должен меняться от
|
||||
// вызова к вызову (иначе у <img> меняется src и браузер перекачивает файл).
|
||||
const { adapter } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const first = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
|
||||
const second = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('экранирует ключ посегментно, сохраняя слэши как разделители', async () => {
|
||||
|
||||
+18
-1
@@ -32,6 +32,15 @@ import {
|
||||
} from './file-storage.config';
|
||||
import { signReadUrl } from './signing';
|
||||
|
||||
/**
|
||||
* Окно стабилизации signed-URL чтения (сек). `exp` округляется к границе окна,
|
||||
* поэтому повторные `getReadUrl` для одного объекта в пределах окна дают
|
||||
* идентичный URL. Без этого каждый запрос (например, 30-секундный polling
|
||||
* списка «Мои предложения») генерировал новый `exp`/`sig` → у `<img>` менялся
|
||||
* `src` → браузер перекачивал картинку и мигал лоудером.
|
||||
*/
|
||||
const READ_URL_STABILIZATION_WINDOW_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* Адаптер `InterFileStoragePort` поверх MinIO/S3. Реализация одна и та же для обоих —
|
||||
* различается только `endpoint` и `forcePathStyle`. На bootstrap идемпотентно создаёт
|
||||
@@ -203,7 +212,15 @@ class MinioBucketHandle implements InterFileStorageBucket {
|
||||
|
||||
async getReadUrl(key: string, opts?: InterFileStorageGetReadUrlOptions): Promise<string> {
|
||||
const ttl = opts?.ttlSeconds ?? this.defaultTtlSeconds;
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
// Округляем exp вверх к границе окна стабилизации: один и тот же объект
|
||||
// при повторных запросах даёт идентичный URL (см.
|
||||
// READ_URL_STABILIZATION_WINDOW_SECONDS). exp всегда ≥ now+ttl —
|
||||
// эффективный TTL клиента от ttl до ttl+окно, подпись валидна.
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const windowStart =
|
||||
Math.floor(nowSec / READ_URL_STABILIZATION_WINDOW_SECONDS) *
|
||||
READ_URL_STABILIZATION_WINDOW_SECONDS;
|
||||
const exp = windowStart + READ_URL_STABILIZATION_WINDOW_SECONDS + ttl;
|
||||
const sig = signReadUrl({
|
||||
bucket: this.publicBucketName,
|
||||
key,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* - create: статус PENDING_MODERATION; rate-limit 10/час; неизвестная
|
||||
* категория → 400; валидация product_name/description/unit/cycle/price;
|
||||
* unlimited_flag=true обнуляет quantity_available;
|
||||
* - update: ownership check (403 чужому); REJECTED/WITHDRAWN → 403;
|
||||
* - update: ownership check (403 чужому); WITHDRAWN → 403; REJECTED →
|
||||
* правка проходит и уходит на повторную модерацию;
|
||||
* reset status в PENDING_MODERATION; валидация полей;
|
||||
* - withdraw: ownership check; статус → WITHDRAWN; блок при активных
|
||||
* ордерах (stub false до Эпика 4);
|
||||
@@ -362,7 +363,7 @@ describe('MarketplaceOfferService.update', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('update WITHDRAWN/REJECTED → 403', async () => {
|
||||
it('update WITHDRAWN → 403 (сначала republish)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
|
||||
@@ -373,6 +374,42 @@ describe('MarketplaceOfferService.update', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('update REJECTED → правка проходит и уходит на повторную модерацию (status PENDING, причина очищена)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(
|
||||
makeOffer({ status: 'REJECTED', reject_reason: 'Плохое фото', rejected_by: 'chairman' })
|
||||
);
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
const result = await service.update('offer-1', 'alice', { product_name: 'Исправлено' });
|
||||
expect(repo.applyUpdate).toHaveBeenCalledWith(
|
||||
'offer-1',
|
||||
expect.objectContaining({
|
||||
product_name: 'Исправлено',
|
||||
status: 'PENDING_MODERATION',
|
||||
reject_reason: null,
|
||||
rejected_by: null,
|
||||
})
|
||||
);
|
||||
expect(result.status).toBe('PENDING_MODERATION');
|
||||
});
|
||||
|
||||
it('update REJECTED только цены → всё равно уходит на повторную модерацию', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'REJECTED' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await service.update('offer-1', 'alice', { price_per_unit: '99.0000' });
|
||||
expect(repo.applyUpdate).toHaveBeenCalledWith(
|
||||
'offer-1',
|
||||
expect.objectContaining({ status: 'PENDING_MODERATION' })
|
||||
);
|
||||
});
|
||||
|
||||
it('update несуществующего → 404', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
@@ -384,18 +421,21 @@ describe('MarketplaceOfferService.update', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('unlimited_flag=true в patch обнуляет quantity_available', async () => {
|
||||
it('unlimited_flag=true в patch обнуляет quantity_available и НЕ сбрасывает статус (операционное поле)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ unlimited_flag: true, quantity_available: 0 }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE', unlimited_flag: true, quantity_available: 0 }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await service.update('offer-1', 'alice', { unlimited_flag: true });
|
||||
expect(repo.applyUpdate).toHaveBeenCalledWith(
|
||||
'offer-1',
|
||||
expect.objectContaining({ unlimited_flag: true, quantity_available: 0, status: 'PENDING_MODERATION' })
|
||||
expect.objectContaining({ unlimited_flag: true, quantity_available: 0 })
|
||||
);
|
||||
// unlimited_flag — операционное поле: статус активной оферты не трогаем.
|
||||
const patchArg = repo.applyUpdate.mock.calls[0][1];
|
||||
expect(patchArg).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('update с invalid category → 400', async () => {
|
||||
@@ -410,6 +450,102 @@ describe('MarketplaceOfferService.update', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketplaceOfferService.update — поле-зависимая модерация', () => {
|
||||
// Операционные поля (цена, остаток, безлимит) поставщик меняет без участия
|
||||
// председателя: активная оферта остаётся ACTIVE, на повторную модерацию не
|
||||
// уходит. Контентные поля (название/описание/категория/фото/единица/цикл)
|
||||
// снова шлют на модерацию.
|
||||
|
||||
it('правка только цены на ACTIVE → статус НЕ сбрасывается, approve-поля не трогаются', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE', price_per_unit: '99.0000' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
const result = await service.update('offer-1', 'alice', { price_per_unit: '99.00' });
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
const patchArg = repo.applyUpdate.mock.calls[0][1];
|
||||
expect(patchArg).not.toHaveProperty('status');
|
||||
expect(patchArg).not.toHaveProperty('approved_by');
|
||||
expect(patchArg).not.toHaveProperty('reject_reason');
|
||||
});
|
||||
|
||||
it('правка только количества на ACTIVE → статус НЕ сбрасывается', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE', quantity_available: 250 }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await service.update('offer-1', 'alice', { quantity_available: 250 });
|
||||
expect(repo.applyUpdate.mock.calls[0][1]).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('правка цены + количества вместе на ACTIVE → статус НЕ сбрасывается', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await service.update('offer-1', 'alice', { price_per_unit: '12.00', quantity_available: 5 });
|
||||
expect(repo.applyUpdate.mock.calls[0][1]).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('правка описания на ACTIVE → статус сбрасывается в PENDING_MODERATION', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await service.update('offer-1', 'alice', { description: 'Новое описание' });
|
||||
expect(repo.applyUpdate).toHaveBeenCalledWith(
|
||||
'offer-1',
|
||||
expect.objectContaining({ description: 'Новое описание', status: 'PENDING_MODERATION', reject_reason: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('смена единицы измерения на ACTIVE → статус сбрасывается (контентное поле)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await service.update('offer-1', 'alice', { unit_of_measure: 'liter' });
|
||||
expect(repo.applyUpdate.mock.calls[0][1]).toMatchObject({ status: 'PENDING_MODERATION' });
|
||||
});
|
||||
|
||||
it('замена изображений на ACTIVE → статус сбрасывается (контентное изменение)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
const images = {
|
||||
putImage: jest.fn().mockResolvedValue({
|
||||
bucket_key: 'offers/voskhod/alice/h.jpg',
|
||||
content_hash: 'h',
|
||||
mime_type: 'image/jpeg',
|
||||
}),
|
||||
getReadUrl: jest.fn().mockResolvedValue('https://signed.example/img'),
|
||||
deleteImage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
|
||||
const service = new MarketplaceOfferService(
|
||||
repo,
|
||||
cats,
|
||||
makeOrderRepo(),
|
||||
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
|
||||
);
|
||||
|
||||
await service.update('offer-1', 'alice', {}, [
|
||||
{ base64: Buffer.from('a').toString('base64'), mime_type: 'image/jpeg' },
|
||||
]);
|
||||
expect(repo.applyUpdate.mock.calls[0][1]).toMatchObject({ status: 'PENDING_MODERATION' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketplaceOfferService.withdraw', () => {
|
||||
it('withdraw → status WITHDRAWN', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
@@ -467,6 +603,47 @@ describe('MarketplaceOfferService.withdraw', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketplaceOfferService.republish', () => {
|
||||
it('republish снятого → status PENDING_MODERATION (без пересоздания)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
|
||||
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
const result = await service.republish('offer-1', 'alice');
|
||||
expect(repo.applyUpdate).toHaveBeenCalledWith('offer-1', { status: 'PENDING_MODERATION' });
|
||||
expect(result.status).toBe('PENDING_MODERATION');
|
||||
});
|
||||
|
||||
it('republish чужого → 403', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ supplier_account: 'alice', status: 'WITHDRAWN' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await expect(service.republish('offer-1', 'mallory')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('republish не-снятого (ACTIVE) → 403', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await expect(service.republish('offer-1', 'alice')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('republish несуществующего → 404', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
repo.findById.mockResolvedValue(null);
|
||||
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
|
||||
|
||||
await expect(service.republish('offer-x', 'alice')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarketplaceOfferService.listMine + getById', () => {
|
||||
it('listMine делегирует в репозиторий с фильтром supplier_account', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
@@ -789,4 +966,72 @@ describe('MarketplaceOfferService — изображения', () => {
|
||||
).rejects.toThrow('db down');
|
||||
expect(images.deleteImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('update: сохраняет существующее по bucket_key и добавляет новое base64 (порядок = показ)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
const images = imagesMock();
|
||||
const existing = {
|
||||
bucket_key: 'offers/voskhod/alice/keep.jpg',
|
||||
content_hash: 'keep',
|
||||
mime_type: 'image/jpeg',
|
||||
};
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE', images: [existing] }));
|
||||
repo.applyUpdate.mockImplementation((_id, patch) => Promise.resolve(makeOffer(patch as object)));
|
||||
const service = new MarketplaceOfferService(
|
||||
repo,
|
||||
cats,
|
||||
makeOrderRepo(),
|
||||
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
|
||||
);
|
||||
|
||||
const result = await service.update('offer-1', 'alice', {}, [
|
||||
{ bucket_key: existing.bucket_key },
|
||||
{ base64: Buffer.from('new').toString('base64'), mime_type: 'image/png' },
|
||||
]);
|
||||
|
||||
// Существующее не перезагружается, новое — одно.
|
||||
expect(images.putImage).toHaveBeenCalledTimes(1);
|
||||
expect(result.images).toHaveLength(2);
|
||||
expect(result.images[0].bucket_key).toBe(existing.bucket_key);
|
||||
expect(result.images[1].mime_type).toBe('image/png');
|
||||
});
|
||||
|
||||
it('update: удаление существующего = просто не передаём его bucket_key (набор сокращается)', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
const images = imagesMock();
|
||||
const a = { bucket_key: 'offers/voskhod/alice/a.jpg', content_hash: 'a', mime_type: 'image/jpeg' };
|
||||
const b = { bucket_key: 'offers/voskhod/alice/b.jpg', content_hash: 'b', mime_type: 'image/jpeg' };
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE', images: [a, b] }));
|
||||
repo.applyUpdate.mockImplementation((_id, patch) => Promise.resolve(makeOffer(patch as object)));
|
||||
const service = new MarketplaceOfferService(
|
||||
repo,
|
||||
cats,
|
||||
makeOrderRepo(),
|
||||
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
|
||||
);
|
||||
|
||||
const result = await service.update('offer-1', 'alice', {}, [{ bucket_key: b.bucket_key }]);
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.images[0].bucket_key).toBe(b.bucket_key);
|
||||
expect(images.putImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update: ссылка на чужой/неизвестный bucket_key → 400', async () => {
|
||||
const repo = makeOfferRepo();
|
||||
const cats = makeCategoryRepo();
|
||||
const images = imagesMock();
|
||||
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE', images: [] }));
|
||||
const service = new MarketplaceOfferService(
|
||||
repo,
|
||||
cats,
|
||||
makeOrderRepo(),
|
||||
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.update('offer-1', 'alice', {}, [{ bucket_key: 'offers/voskhod/mallory/stolen.jpg' }])
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -802,6 +802,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
MarketplaceRemoveFromWhitelistInput:{
|
||||
|
||||
},
|
||||
MarketplaceRepublishOfferInput:{
|
||||
|
||||
},
|
||||
MarketplaceReturnClaimDefectCategory: "enum" as const,
|
||||
MarketplaceReturnClaimExpectedResolution: "enum" as const,
|
||||
@@ -1411,6 +1414,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
marketplaceReplaceAvailableItems:{
|
||||
input:"ReplaceAvailableItemsInput"
|
||||
},
|
||||
marketplaceRepublishOffer:{
|
||||
input:"MarketplaceRepublishOfferInput"
|
||||
},
|
||||
marketplaceRetryKUGeocode:{
|
||||
|
||||
},
|
||||
@@ -4154,6 +4160,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
warranty_days:"Int"
|
||||
},
|
||||
MarketplaceOfferImage:{
|
||||
bucket_key:"String",
|
||||
is_cover:"Boolean",
|
||||
mime_type:"String",
|
||||
sort_order:"Int",
|
||||
@@ -4761,6 +4768,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
marketplaceRemoveAvailableCategoryTypes:"Boolean",
|
||||
marketplaceRemoveFromWhitelist:"Boolean",
|
||||
marketplaceReplaceAvailableItems:"MarketplaceAvailableCategory",
|
||||
marketplaceRepublishOffer:"MarketplaceOffer",
|
||||
marketplaceRetryKUGeocode:"MarketplaceKUDetails",
|
||||
marketplaceSetKUStatus:"MarketplaceKUDetails",
|
||||
marketplaceSignAplReceptionAsChairman:"MarketplaceAplReceptionResult",
|
||||
|
||||
@@ -7005,6 +7005,8 @@ export type ValueTypes = {
|
||||
['...on MarketplaceOffer']?: Omit<ValueTypes["MarketplaceOffer"], "...on MarketplaceOffer">
|
||||
}>;
|
||||
["MarketplaceOfferImage"]: AliasType<{
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key?:boolean | `@${string}`,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover?:boolean | `@${string}`,
|
||||
/** MIME-тип изображения. */
|
||||
@@ -7017,10 +7019,12 @@ export type ValueTypes = {
|
||||
['...on MarketplaceOfferImage']?: Omit<ValueTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
|
||||
}>;
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string | Variable<any, string>,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string | Variable<any, string>
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null | Variable<any, string>,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null | Variable<any, string>,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null | Variable<any, string>
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: AliasType<{
|
||||
/** Текущая страница */
|
||||
@@ -7255,6 +7259,9 @@ export type ValueTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string | Variable<any, string>
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string | Variable<any, string>
|
||||
};
|
||||
["MarketplaceRequest"]: AliasType<{
|
||||
/** Артикул товара */
|
||||
@@ -8245,6 +8252,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ValueTypes["RemoveAvailableCate
|
||||
marketplaceRemoveAvailableCategoryTypes?: [{ input: ValueTypes["RemoveAvailableCategoryTypesInput"] | Variable<any, string>},boolean | `@${string}`],
|
||||
marketplaceRemoveFromWhitelist?: [{ input: ValueTypes["MarketplaceRemoveFromWhitelistInput"] | Variable<any, string>},boolean | `@${string}`],
|
||||
marketplaceReplaceAvailableItems?: [{ input: ValueTypes["ReplaceAvailableItemsInput"] | Variable<any, string>},ValueTypes["MarketplaceAvailableCategory"]],
|
||||
marketplaceRepublishOffer?: [{ input: ValueTypes["MarketplaceRepublishOfferInput"] | Variable<any, string>},ValueTypes["MarketplaceOffer"]],
|
||||
marketplaceRetryKUGeocode?: [{ coopname: string | Variable<any, string>, coreBraname: string | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSetKUStatus?: [{ data: ValueTypes["MarketplaceSetKUStatusInput"] | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSignAplReceptionAsChairman?: [{ data: ValueTypes["MarketplaceSignAplReceptionInput"] | Variable<any, string>},ValueTypes["MarketplaceAplReceptionResult"]],
|
||||
@@ -17030,6 +17038,8 @@ export type ResolverInputTypes = {
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["MarketplaceOfferImage"]: AliasType<{
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key?:boolean | `@${string}`,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover?:boolean | `@${string}`,
|
||||
/** MIME-тип изображения. */
|
||||
@@ -17041,10 +17051,12 @@ export type ResolverInputTypes = {
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: AliasType<{
|
||||
/** Текущая страница */
|
||||
@@ -17270,6 +17282,9 @@ export type ResolverInputTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string
|
||||
};
|
||||
["MarketplaceRequest"]: AliasType<{
|
||||
/** Артикул товара */
|
||||
@@ -18232,6 +18247,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ResolverInputTypes["RemoveAvail
|
||||
marketplaceRemoveAvailableCategoryTypes?: [{ input: ResolverInputTypes["RemoveAvailableCategoryTypesInput"]},boolean | `@${string}`],
|
||||
marketplaceRemoveFromWhitelist?: [{ input: ResolverInputTypes["MarketplaceRemoveFromWhitelistInput"]},boolean | `@${string}`],
|
||||
marketplaceReplaceAvailableItems?: [{ input: ResolverInputTypes["ReplaceAvailableItemsInput"]},ResolverInputTypes["MarketplaceAvailableCategory"]],
|
||||
marketplaceRepublishOffer?: [{ input: ResolverInputTypes["MarketplaceRepublishOfferInput"]},ResolverInputTypes["MarketplaceOffer"]],
|
||||
marketplaceRetryKUGeocode?: [{ coopname: string, coreBraname: string},ResolverInputTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSetKUStatus?: [{ data: ResolverInputTypes["MarketplaceSetKUStatusInput"]},ResolverInputTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSignAplReceptionAsChairman?: [{ data: ResolverInputTypes["MarketplaceSignAplReceptionInput"]},ResolverInputTypes["MarketplaceAplReceptionResult"]],
|
||||
@@ -26730,7 +26746,9 @@ export type ModelTypes = {
|
||||
warranty_days: number
|
||||
};
|
||||
["MarketplaceOfferImage"]: {
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key: string,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover: boolean,
|
||||
/** MIME-тип изображения. */
|
||||
mime_type: string,
|
||||
@@ -26740,10 +26758,12 @@ export type ModelTypes = {
|
||||
url: string
|
||||
};
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: {
|
||||
/** Текущая страница */
|
||||
@@ -26956,6 +26976,9 @@ export type ModelTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string
|
||||
};
|
||||
["MarketplaceRequest"]: {
|
||||
/** Артикул товара */
|
||||
@@ -28320,6 +28343,8 @@ export type ModelTypes = {
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
marketplaceReplaceAvailableItems: Array<ModelTypes["MarketplaceAvailableCategory"]>,
|
||||
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
|
||||
marketplaceRepublishOffer: ModelTypes["MarketplaceOffer"],
|
||||
/** Повторно запускает геокодинг адреса ПВЗ.
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
@@ -37365,6 +37390,8 @@ export type GraphQLTypes = {
|
||||
};
|
||||
["MarketplaceOfferImage"]: {
|
||||
__typename: "MarketplaceOfferImage",
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key: string,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover: boolean,
|
||||
/** MIME-тип изображения. */
|
||||
@@ -37376,10 +37403,12 @@ export type GraphQLTypes = {
|
||||
['...on MarketplaceOfferImage']: Omit<GraphQLTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
|
||||
};
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: {
|
||||
__typename: "MarketplaceOfferPaginationResult",
|
||||
@@ -37614,6 +37643,9 @@ export type GraphQLTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string
|
||||
};
|
||||
["MarketplaceRequest"]: {
|
||||
__typename: "MarketplaceRequest",
|
||||
@@ -39042,6 +39074,8 @@ export type GraphQLTypes = {
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
marketplaceReplaceAvailableItems: Array<GraphQLTypes["MarketplaceAvailableCategory"]>,
|
||||
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
|
||||
marketplaceRepublishOffer: GraphQLTypes["MarketplaceOffer"],
|
||||
/** Повторно запускает геокодинг адреса ПВЗ.
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
@@ -43278,6 +43312,7 @@ type ZEUS_VARIABLES = {
|
||||
["MarketplaceRejectReturnAtVisitInput"]: ValueTypes["MarketplaceRejectReturnAtVisitInput"];
|
||||
["MarketplaceRejectReturnRemoteInput"]: ValueTypes["MarketplaceRejectReturnRemoteInput"];
|
||||
["MarketplaceRemoveFromWhitelistInput"]: ValueTypes["MarketplaceRemoveFromWhitelistInput"];
|
||||
["MarketplaceRepublishOfferInput"]: ValueTypes["MarketplaceRepublishOfferInput"];
|
||||
["MarketplaceReturnClaimDefectCategory"]: ValueTypes["MarketplaceReturnClaimDefectCategory"];
|
||||
["MarketplaceReturnClaimExpectedResolution"]: ValueTypes["MarketplaceReturnClaimExpectedResolution"];
|
||||
["MarketplaceReturnClaimPhotoUploadInput"]: ValueTypes["MarketplaceReturnClaimPhotoUploadInput"];
|
||||
|
||||
@@ -230,6 +230,9 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
// Скрыт из меню (hidden) — кнопка «Создать предложение» живёт по
|
||||
// канону в правом верхнем углу шапки (телепорт через
|
||||
// useHeaderActions со стола «Мои предложения»).
|
||||
path: 'create-offer',
|
||||
name: 'marketplace-create-offer',
|
||||
component: markRaw(CreateMarketplaceOfferPage),
|
||||
@@ -239,6 +242,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
|
||||
requires: 'Offer:read',
|
||||
requiresAuth: true,
|
||||
agreements: agreementsBase,
|
||||
hidden: true,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
@@ -53,3 +53,30 @@ export async function fetchMyOfferById(
|
||||
};
|
||||
return page.items.find((o) => o.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поставщик снимает своё предложение с публикации (статус → WITHDRAWN).
|
||||
* Backend: marketplace-offer.resolver.ts → marketplaceWithdrawOffer
|
||||
* (guard 'Offer' 'delete:own', ownership проверяется в сервисе).
|
||||
*/
|
||||
export async function withdrawOffer(id: string): Promise<void> {
|
||||
await client.Mutation(Mutations.Marketplace.WithdrawOffer.mutation, {
|
||||
variables: { input: { id } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Эпик 4 / Story 4.2: поставщик вручную запускает поставку по своему
|
||||
* предложению с открытой подпиской (cycle_type=open_subscription).
|
||||
*
|
||||
* Backend Resolver: marketplace-cycle.resolver.ts → marketplaceTriggerOpenSubscription
|
||||
* (guard 'Offer' 'update:own'). Нажатие = акцепт всего накопленного пула:
|
||||
* сервер формирует сводную заявку status=ACCEPTED и принимает заказы разом.
|
||||
* Ошибки backend (пустой пул, не open_subscription, не ACTIVE, чужой Offer)
|
||||
* приходят как GraphQL-исключения — пробрасываем их вызывающему компоненту.
|
||||
*/
|
||||
export async function triggerOpenSubscription(offer_id: string): Promise<void> {
|
||||
await client.Mutation(Mutations.Marketplace.TriggerOpenSubscription.mutation, {
|
||||
variables: { input: { offer_id } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,15 +13,22 @@ export type MarketplaceOfferCycleType =
|
||||
|
||||
export type MarketplaceUnitOfMeasure = 'piece' | 'kg' | 'liter' | 'pack';
|
||||
|
||||
/** Новое загружаемое изображение (содержимое в base64 + MIME). */
|
||||
/**
|
||||
* Элемент набора изображений в payload. ЛИБО новый файл (base64 + mime_type),
|
||||
* ЛИБО ссылка на уже сохранённое изображение (bucket_key — сохранить его в
|
||||
* наборе). Порядок в массиве = порядок показа, первый — обложка.
|
||||
*/
|
||||
export interface MarketplaceOfferImageUpload {
|
||||
base64: string;
|
||||
mime_type: string;
|
||||
base64?: string;
|
||||
mime_type?: string;
|
||||
bucket_key?: string;
|
||||
}
|
||||
|
||||
/** Уже сохранённое изображение (приходит с backend как подписанный URL). */
|
||||
export interface MarketplaceOfferImageView {
|
||||
url: string;
|
||||
/** Стабильный ключ хранилища — для сохранения изображения при правке набора. */
|
||||
bucket_key: string;
|
||||
mime_type: string;
|
||||
sort_order: number;
|
||||
is_cover: boolean;
|
||||
@@ -69,9 +76,10 @@ export interface MarketplaceCreateOfferPayload {
|
||||
min_threshold: number | null;
|
||||
warranty_days: number;
|
||||
/**
|
||||
* Изображения товара (base64). При создании — набор новых файлов (может
|
||||
* быть пустым). При обновлении — если передано, полностью заменяет текущий
|
||||
* набор; если опущено (undefined) — изображения не трогаются.
|
||||
* Итоговый упорядоченный набор изображений (первый — обложка). При создании —
|
||||
* только новые файлы (base64). При обновлении — смесь: уже сохранённые
|
||||
* (bucket_key, остаются) + новые (base64). Если опущено (undefined) —
|
||||
* изображения не трогаются и оффер не уходит на повторную модерацию.
|
||||
*/
|
||||
images?: MarketplaceOfferImageUpload[];
|
||||
}
|
||||
@@ -113,5 +121,6 @@ export interface MarketplaceOfferEditPrefill {
|
||||
min_threshold: number | null;
|
||||
warranty_days: number;
|
||||
status: 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
|
||||
reject_reason?: string | null;
|
||||
images?: MarketplaceOfferImageView[];
|
||||
}
|
||||
|
||||
+281
-96
@@ -1,22 +1,58 @@
|
||||
<template lang="pug">
|
||||
q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание предложения')
|
||||
.offer-wizard__col
|
||||
header.offer-wizard__head
|
||||
h1.offer-wizard__title {{ pageTitle }}
|
||||
p.offer-wizard__intro
|
||||
| Заполните карточку товара по шагам. После публикации предложение
|
||||
| уходит на модерацию председателю — до одобрения оно не появится в каталоге.
|
||||
//- Заголовок страницы — в топбаре (route.meta.title), на странице не
|
||||
//- дублируется. Верхняя строка режима редактирования: статус слева,
|
||||
//- операционные действия (снять / запустить поставку) — в правом углу.
|
||||
.offer-wizard__manage(v-if='isEdit && currentStatus')
|
||||
BaseChip(:variant='statusVariant', size='lg') {{ statusLabel }}
|
||||
q-space
|
||||
BaseButton(
|
||||
v-if='canTrigger',
|
||||
variant='secondary',
|
||||
:loading='triggering',
|
||||
@click='onTrigger'
|
||||
)
|
||||
q-icon(name='local_shipping', size='16px')
|
||||
span.q-ml-sm Запустить поставку
|
||||
BaseButton(
|
||||
v-if='canWithdraw',
|
||||
variant='danger',
|
||||
:loading='withdrawing',
|
||||
@click='onWithdraw'
|
||||
)
|
||||
q-icon(name='visibility_off', size='16px')
|
||||
span.q-ml-sm Снять с публикации
|
||||
|
||||
q-banner.offer-wizard__notice(v-if='isEdit', rounded, dense)
|
||||
template(#avatar)
|
||||
q-icon(name='fa-solid fa-circle-info', color='warning')
|
||||
| После сохранения предложение снова отправится на модерацию — до одобрения
|
||||
| оно будет недоступно в каталоге.
|
||||
//- Отклонённая оферта: показываем причину председателя. Поставщик правит
|
||||
//- карточку и переотправляет — пересоздавать заново не нужно.
|
||||
.banner.banner--neg(v-if='isEdit && currentStatus === "REJECTED"')
|
||||
q-icon.banner__icon(name='cancel', size='18px')
|
||||
.banner__body
|
||||
.text-weight-medium Предложение отклонено модератором
|
||||
.q-mt-xs(v-if='rejectReason') Причина: {{ rejectReason }}
|
||||
.q-mt-xs Исправьте указанное и нажмите «Отправить на модерацию» — оферта уйдёт на повторную проверку с тем же содержимым.
|
||||
|
||||
q-inner-loading(:showing='prefilling')
|
||||
q-spinner(color='primary', size='2em')
|
||||
//- Канон-карточка информации (одна на страницу): подсказка по заполнению +
|
||||
//- правила модерации. Для отклонённой показываем баннер причины выше — этот
|
||||
//- не дублируем.
|
||||
.banner.banner--info(v-if='currentStatus !== "REJECTED"')
|
||||
q-icon.banner__icon(name='info', size='18px')
|
||||
.banner__body {{ infoText }}
|
||||
|
||||
//- Скелетон формы на время дозагрузки оферты в режиме редактирования:
|
||||
//- степпер монтируем только когда данные готовы, чтобы поля не
|
||||
//- «дозаполнялись» на глазах (без дёргания). В режиме создания prefilling
|
||||
//- всегда false — степпер рендерится сразу.
|
||||
.offer-wizard__skel(v-if='prefilling')
|
||||
.skel.skel--title.offer-wizard__skel-title
|
||||
.skel.skel--text.offer-wizard__skel-line
|
||||
.skel.skel--text.offer-wizard__skel-line.offer-wizard__skel-line--wide
|
||||
.skel.skel--text.offer-wizard__skel-line.offer-wizard__skel-line--narrow
|
||||
.skel.skel--text.offer-wizard__skel-line
|
||||
|
||||
VerticalStepper(
|
||||
v-else,
|
||||
:steps='steps',
|
||||
:active-key='activeKey',
|
||||
:completed='completedKeys',
|
||||
@@ -198,21 +234,12 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
|
||||
.offer-wizard__step(v-else-if='step.key === "images"')
|
||||
p.offer-wizard__hint
|
||||
| До {{ MAX_IMAGES }} изображений, каждое до {{ MAX_MB }} МБ (JPEG, PNG или WEBP).
|
||||
| Нажмите на снимок, чтобы сделать его обложкой карточки.
|
||||
| Нажмите на снимок, чтобы сделать его обложкой карточки; крестик — удалить.
|
||||
|
||||
.offer-wizard__existing(v-if='isEdit && existingImages.length && !imageDrafts.length')
|
||||
.offer-wizard__field-label.offer-wizard__field-label--sub Текущие изображения
|
||||
.offer-wizard__grid
|
||||
.offer-wizard__thumb(v-for='(img, i) in existingImages', :key='img.url')
|
||||
q-img.offer-wizard__img(:src='img.url', ratio='1')
|
||||
span.offer-wizard__cover(v-if='i === 0') Обложка
|
||||
p.offer-wizard__hint.offer-wizard__hint--muted
|
||||
| Загрузка новых изображений заменит текущие.
|
||||
|
||||
.offer-wizard__grid(v-if='imageDrafts.length')
|
||||
.offer-wizard__grid(v-if='gallery.length')
|
||||
.offer-wizard__thumb(
|
||||
v-for='(img, i) in imageDrafts',
|
||||
:key='img.preview_url',
|
||||
v-for='(img, i) in gallery',
|
||||
:key='img.uid',
|
||||
:class='{ "offer-wizard__thumb--cover": i === coverIndex }',
|
||||
:title='i === coverIndex ? "Это обложка" : "Сделать обложкой"',
|
||||
role='button',
|
||||
@@ -220,7 +247,7 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
|
||||
@click='setCover(i)',
|
||||
@keydown.enter='setCover(i)'
|
||||
)
|
||||
q-img.offer-wizard__img(:src='img.preview_url', ratio='1')
|
||||
q-img.offer-wizard__img(:src='img.url', ratio='1')
|
||||
span.offer-wizard__cover(v-if='i === coverIndex') Обложка
|
||||
span.offer-wizard__set(v-else) Сделать обложкой
|
||||
q-btn.offer-wizard__remove(
|
||||
@@ -240,11 +267,11 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
|
||||
dense,
|
||||
multiple,
|
||||
accept='image/jpeg,image/png,image/webp',
|
||||
:disable='imageDrafts.length >= MAX_IMAGES',
|
||||
:disable='gallery.length >= MAX_IMAGES',
|
||||
@update:model-value='onPickFiles'
|
||||
)
|
||||
template(#prepend)
|
||||
q-icon(name='fa-solid fa-image')
|
||||
q-icon(name='image')
|
||||
|
||||
//- ───────── Шаг 5: Проверка (карточка-предпросмотр) ─────────
|
||||
.offer-wizard__step(v-else-if='step.key === "review"')
|
||||
@@ -319,13 +346,13 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
|
||||
span.q-mr-sm Далее
|
||||
q-icon(name='arrow_forward', size='16px')
|
||||
BaseButton(v-else, variant='primary', :loading='submitting', @click='onSubmit')
|
||||
q-icon(name='fa-solid fa-paper-plane', size='14px')
|
||||
q-icon(name='send', size='16px')
|
||||
span.q-ml-sm {{ submitLabel }}
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { LocalStorage } from 'quasar';
|
||||
import { Dialog, LocalStorage, Notify } from 'quasar';
|
||||
import type { QForm } from 'quasar';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { VerticalStepper } from 'src/shared/ui/domain/VerticalStepper';
|
||||
@@ -338,15 +365,20 @@ import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { MARKETPLACE_UNIT_OPTIONS } from 'src/shared/lib/consts';
|
||||
import { fileToBase64, formatAsset2Digits } from 'src/shared/lib/utils';
|
||||
import { createOffer, fetchCategories, fetchMyOfferById, updateOffer } from '../api';
|
||||
import {
|
||||
createOffer,
|
||||
fetchCategories,
|
||||
fetchMyOfferById,
|
||||
triggerOpenSubscription,
|
||||
updateOffer,
|
||||
withdrawOffer,
|
||||
} from '../api';
|
||||
import type {
|
||||
MarketplaceCategoryView,
|
||||
MarketplaceCreateOfferFormState,
|
||||
MarketplaceCreateOfferPayload,
|
||||
MarketplaceOfferCycleType,
|
||||
MarketplaceOfferImageDraft,
|
||||
MarketplaceOfferImageUpload,
|
||||
MarketplaceOfferImageView,
|
||||
MarketplaceUnitOfMeasure,
|
||||
} from '../types';
|
||||
|
||||
@@ -384,6 +416,13 @@ const priceRule = (v: string): true | string =>
|
||||
/^\d+([.,]\d{1,2})?$/.test((v ?? '').trim()) ||
|
||||
'Цена — число с двумя знаками после запятой, например 100.50';
|
||||
|
||||
// Цена из БД приходит с большей точностью (напр. «100.0000») и не проходит
|
||||
// priceRule. При prefill приводим к виду поля ввода — 2 знака после точки.
|
||||
function formatPriceForInput(raw: string | number | null | undefined): string {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '';
|
||||
}
|
||||
|
||||
const editId = computed(() => {
|
||||
const p = route.params.offerId;
|
||||
return typeof p === 'string' && p ? p : null;
|
||||
@@ -392,10 +431,101 @@ const isEdit = computed(() => editId.value !== null);
|
||||
const prefilling = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const pageTitle = computed(() => (isEdit.value ? 'Редактирование предложения' : 'Новое предложение'));
|
||||
const submitLabel = computed(() =>
|
||||
isEdit.value ? 'Сохранить и отправить на модерацию' : 'Опубликовать на модерацию'
|
||||
// Единая карточка-подсказка: при создании — про публикацию и модерацию, при
|
||||
// редактировании — что цена/остаток применяются сразу, а контент уходит на
|
||||
// повторную модерацию.
|
||||
const infoText = computed(() =>
|
||||
isEdit.value
|
||||
? 'Заполните карточку товара по шагам. Цена и количество применяются сразу. Изменение названия, описания, категории, единицы измерения или фотографий снова отправит предложение на модерацию — до одобрения оно будет недоступно в каталоге.'
|
||||
: 'Заполните карточку товара по шагам. После публикации предложение уходит на модерацию председателю — до одобрения оно не появится в каталоге.'
|
||||
);
|
||||
const submitLabel = computed(() => {
|
||||
if (!isEdit.value) return 'Опубликовать на модерацию';
|
||||
// Отклонённую правят, чтобы переотправить — подпись честно говорит, что
|
||||
// сохранение снова отправит оферту на модерацию.
|
||||
if (currentStatus.value === 'REJECTED') return 'Отправить на модерацию';
|
||||
return 'Сохранить изменения';
|
||||
});
|
||||
|
||||
// ===== Управление офертой (только режим редактирования) =====
|
||||
// Текущий статус оферты + операционные действия (снять / запустить поставку)
|
||||
// живут на самой странице редактирования — отдельного диалога-просмотра нет.
|
||||
type OfferStatus = 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
|
||||
const currentStatus = ref<OfferStatus | null>(null);
|
||||
const currentCycleType = ref<MarketplaceOfferCycleType | null>(null);
|
||||
const rejectReason = ref<string | null>(null);
|
||||
const withdrawing = ref(false);
|
||||
const triggering = ref(false);
|
||||
|
||||
const STATUS_META: Record<OfferStatus, { label: string; variant: 'neutral' | 'pos' | 'neg' | 'warn' }> = {
|
||||
PENDING_MODERATION: { label: 'На модерации', variant: 'warn' },
|
||||
ACTIVE: { label: 'Опубликовано', variant: 'pos' },
|
||||
REJECTED: { label: 'Отклонено', variant: 'neg' },
|
||||
WITHDRAWN: { label: 'Снято', variant: 'neutral' },
|
||||
};
|
||||
const statusLabel = computed(() => (currentStatus.value ? STATUS_META[currentStatus.value].label : ''));
|
||||
const statusVariant = computed(() =>
|
||||
currentStatus.value ? STATUS_META[currentStatus.value].variant : 'neutral'
|
||||
);
|
||||
// Снять можно опубликованную или ожидающую модерации; запуск поставки — только
|
||||
// активную оферту с открытой подпиской (см. backend guard'ы).
|
||||
const canWithdraw = computed(
|
||||
() => currentStatus.value === 'PENDING_MODERATION' || currentStatus.value === 'ACTIVE'
|
||||
);
|
||||
const canTrigger = computed(
|
||||
() => currentStatus.value === 'ACTIVE' && currentCycleType.value === 'open_subscription'
|
||||
);
|
||||
|
||||
function onWithdraw(): void {
|
||||
if (!editId.value) return;
|
||||
Dialog.create({
|
||||
title: 'Снять предложение с публикации?',
|
||||
message:
|
||||
'Предложение перестанет показываться в каталоге и не будет принимать новые ' +
|
||||
'заказы. Вернуть его можно будет, только создав заново.',
|
||||
cancel: { label: 'Отмена', flat: true, noCaps: true },
|
||||
ok: { label: 'Снять с публикации', color: 'negative', unelevated: true, noCaps: true },
|
||||
persistent: true,
|
||||
}).onOk(async () => {
|
||||
withdrawing.value = true;
|
||||
try {
|
||||
await withdrawOffer(editId.value as string);
|
||||
Notify.create({ type: 'positive', message: 'Предложение снято с публикации.' });
|
||||
void router.push({ name: 'marketplace-my-offers' });
|
||||
} catch (e) {
|
||||
FailAlert(e, 'Не удалось снять предложение');
|
||||
} finally {
|
||||
withdrawing.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onTrigger(): void {
|
||||
if (!editId.value) return;
|
||||
Dialog.create({
|
||||
title: 'Запустить поставку?',
|
||||
message:
|
||||
'Все накопленные заказы будут разом зафиксированы в одну партию и приняты ' +
|
||||
'к поставке. Отменить запуск нельзя.',
|
||||
cancel: { label: 'Отмена', flat: true, noCaps: true },
|
||||
ok: { label: 'Запустить поставку', color: 'primary', unelevated: true, noCaps: true },
|
||||
persistent: true,
|
||||
}).onOk(async () => {
|
||||
triggering.value = true;
|
||||
try {
|
||||
await triggerOpenSubscription(editId.value as string);
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Поставка запущена: заказы зафиксированы в партию и приняты.',
|
||||
});
|
||||
void router.push({ name: 'marketplace-my-offers' });
|
||||
} catch (e) {
|
||||
FailAlert(e, 'Не удалось запустить поставку');
|
||||
} finally {
|
||||
triggering.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Шаги =====
|
||||
const steps: StepperStep[] = [
|
||||
@@ -470,11 +600,28 @@ const form = ref<MarketplaceCreateOfferFormState>({
|
||||
});
|
||||
|
||||
// ===== Изображения =====
|
||||
// Единая галерея: и уже сохранённые (bucket_key + подписанный url), и новые
|
||||
// (base64 + object-url). Обложку, удаление и добавление можно применять к любому
|
||||
// элементу с самого начала — без обязательной загрузки нового файла.
|
||||
interface GalleryImage {
|
||||
uid: string;
|
||||
url: string;
|
||||
mime_type: string;
|
||||
/** Существующее изображение (сохраняем по ключу). */
|
||||
bucket_key?: string;
|
||||
/** Новый файл. */
|
||||
base64?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const picked = ref<File[] | null>(null);
|
||||
const imageDrafts = ref<MarketplaceOfferImageDraft[]>([]);
|
||||
const existingImages = ref<MarketplaceOfferImageView[]>([]);
|
||||
const gallery = ref<GalleryImage[]>([]);
|
||||
const coverIndex = ref(0);
|
||||
const previewActive = ref(0);
|
||||
// Ключи исходного набора (в исходном порядке) — чтобы при сохранении понять,
|
||||
// менялись ли изображения, и не гнать оффер на повторную модерацию зря.
|
||||
const originalImageKeys = ref<string[]>([]);
|
||||
let galleryUidSeq = 0;
|
||||
|
||||
const isTimeBased = computed(() => form.value.cycle_type === 'time_based');
|
||||
const isVolumeBased = computed(() => form.value.cycle_type === 'volume_based');
|
||||
@@ -536,11 +683,13 @@ function saveDraft(): void {
|
||||
completedKeys: completedKeys.value,
|
||||
coverIndex: coverIndex.value,
|
||||
};
|
||||
const images: OfferDraftImage[] = imageDrafts.value.map((d) => ({
|
||||
base64: d.base64,
|
||||
mime_type: d.mime_type,
|
||||
name: d.name,
|
||||
}));
|
||||
const images: OfferDraftImage[] = gallery.value
|
||||
.filter((g) => g.base64)
|
||||
.map((g) => ({
|
||||
base64: g.base64 as string,
|
||||
mime_type: g.mime_type,
|
||||
name: g.name ?? '',
|
||||
}));
|
||||
const totalChars = images.reduce((n, im) => n + im.base64.length, 0);
|
||||
const payload: OfferDraft =
|
||||
images.length && totalChars <= MAX_DRAFT_IMAGE_CHARS ? { ...base, images } : base;
|
||||
@@ -571,11 +720,12 @@ function restoreDraft(): void {
|
||||
if (Array.isArray(saved.completedKeys)) completedKeys.value = saved.completedKeys;
|
||||
if (Array.isArray(saved.images) && saved.images.length) {
|
||||
// object-URL после reload мёртв — превью восстанавливаем как data-URL из base64.
|
||||
imageDrafts.value = saved.images.map((im) => ({
|
||||
preview_url: `data:${im.mime_type};base64,${im.base64}`,
|
||||
name: im.name,
|
||||
gallery.value = saved.images.map((im) => ({
|
||||
uid: `g${++galleryUidSeq}`,
|
||||
url: `data:${im.mime_type};base64,${im.base64}`,
|
||||
base64: im.base64,
|
||||
mime_type: im.mime_type,
|
||||
name: im.name,
|
||||
}));
|
||||
}
|
||||
if (typeof saved.coverIndex === 'number') coverIndex.value = saved.coverIndex;
|
||||
@@ -589,23 +739,17 @@ function clearDraft(): void {
|
||||
LocalStorage.remove(DRAFT_KEY);
|
||||
}
|
||||
|
||||
// Черновики в порядке «обложка первой» — для payload и предпросмотра.
|
||||
function draftsCoverFirst(): MarketplaceOfferImageDraft[] {
|
||||
const arr = imageDrafts.value;
|
||||
// Галерея в порядке «обложка первой» — для payload и предпросмотра.
|
||||
function galleryCoverFirst(): GalleryImage[] {
|
||||
const arr = gallery.value;
|
||||
if (!arr.length) return [];
|
||||
const ci = Math.min(Math.max(coverIndex.value, 0), arr.length - 1);
|
||||
return [arr[ci], ...arr.filter((_, i) => i !== ci)];
|
||||
}
|
||||
|
||||
const previewImages = computed<Array<{ url: string }>>(() => {
|
||||
if (imageDrafts.value.length) {
|
||||
return draftsCoverFirst().map((d) => ({ url: d.preview_url }));
|
||||
}
|
||||
if (isEdit.value && existingImages.value.length) {
|
||||
return existingImages.value.map((img) => ({ url: img.url }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const previewImages = computed<Array<{ url: string }>>(() =>
|
||||
galleryCoverFirst().map((g) => ({ url: g.url }))
|
||||
);
|
||||
|
||||
function onToggleUnlimited(value: boolean): void {
|
||||
form.value.unlimited_flag = value;
|
||||
@@ -641,11 +785,12 @@ function onSelectCycle(value: MarketplaceOfferCycleType): void {
|
||||
async function onPickFiles(files: readonly File[] | null): Promise<void> {
|
||||
const list = files ? [...files] : [];
|
||||
// Берём только ещё не добавленные файлы (по имени) — на случай повторного выбора.
|
||||
// Добавляем (не заменяем) к уже имеющимся; дедуп новых по имени файла.
|
||||
const fresh = list.filter(
|
||||
(f) => !imageDrafts.value.some((d) => d.name === f.name && d.base64.length > 0)
|
||||
(f) => !gallery.value.some((g) => g.base64 && g.name === f.name)
|
||||
);
|
||||
for (const file of fresh) {
|
||||
if (imageDrafts.value.length >= MAX_IMAGES) {
|
||||
if (gallery.value.length >= MAX_IMAGES) {
|
||||
FailAlert(new Error(`Можно добавить не более ${MAX_IMAGES} изображений.`));
|
||||
break;
|
||||
}
|
||||
@@ -658,11 +803,12 @@ async function onPickFiles(files: readonly File[] | null): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
const base64 = await fileToBase64(file);
|
||||
imageDrafts.value.push({
|
||||
preview_url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
gallery.value.push({
|
||||
uid: `g${++galleryUidSeq}`,
|
||||
url: URL.createObjectURL(file),
|
||||
base64,
|
||||
mime_type: file.type,
|
||||
name: file.name,
|
||||
});
|
||||
}
|
||||
// q-file модель не храним — управляем своим списком превью.
|
||||
@@ -670,13 +816,15 @@ async function onPickFiles(files: readonly File[] | null): Promise<void> {
|
||||
}
|
||||
|
||||
function removeImage(index: number): void {
|
||||
const [removed] = imageDrafts.value.splice(index, 1);
|
||||
if (removed) URL.revokeObjectURL(removed.preview_url);
|
||||
const [removed] = gallery.value.splice(index, 1);
|
||||
// object-url освобождаем только у новых файлов; подписанный url существующих
|
||||
// освобождать не нужно.
|
||||
if (removed?.base64 && removed.url.startsWith('blob:')) URL.revokeObjectURL(removed.url);
|
||||
// Сдвигаем выбор обложки, чтобы он не «уехал» на чужой снимок.
|
||||
if (index === coverIndex.value) coverIndex.value = 0;
|
||||
else if (index < coverIndex.value) coverIndex.value -= 1;
|
||||
if (coverIndex.value > imageDrafts.value.length - 1) {
|
||||
coverIndex.value = Math.max(0, imageDrafts.value.length - 1);
|
||||
if (coverIndex.value > gallery.value.length - 1) {
|
||||
coverIndex.value = Math.max(0, gallery.value.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,13 +890,24 @@ function onCancel(): void {
|
||||
}
|
||||
|
||||
// ===== Сабмит =====
|
||||
// Менялись ли изображения относительно исходного набора (есть новые файлы,
|
||||
// удалили существующее или сменили обложку/порядок).
|
||||
function imagesChanged(): boolean {
|
||||
if (gallery.value.some((g) => g.base64)) return true;
|
||||
const cur = galleryCoverFirst().map((g) => g.bucket_key ?? '');
|
||||
if (cur.length !== originalImageKeys.value.length) return true;
|
||||
return cur.some((k, i) => k !== originalImageKeys.value[i]);
|
||||
}
|
||||
|
||||
function buildImagesPayload(): MarketplaceOfferImageUpload[] | undefined {
|
||||
const fresh = draftsCoverFirst().map((d) => ({ base64: d.base64, mime_type: d.mime_type }));
|
||||
if (isEdit.value) {
|
||||
// В режиме правки изображения трогаем только если выбраны новые файлы.
|
||||
return fresh.length ? fresh : undefined;
|
||||
}
|
||||
return fresh;
|
||||
// В режиме правки не трогаем изображения, если они не менялись — иначе оффер
|
||||
// зря уйдёт на повторную модерацию.
|
||||
if (isEdit.value && !imagesChanged()) return undefined;
|
||||
return galleryCoverFirst().map((g) =>
|
||||
g.bucket_key
|
||||
? { bucket_key: g.bucket_key }
|
||||
: { base64: g.base64 as string, mime_type: g.mime_type }
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
@@ -774,8 +933,13 @@ async function onSubmit(): Promise<void> {
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
const wasRejected = currentStatus.value === 'REJECTED';
|
||||
await updateOffer({ id: editId.value, ...payload });
|
||||
SuccessAlert('Изменения сохранены. Предложение отправлено на повторную модерацию.');
|
||||
SuccessAlert(
|
||||
wasRejected
|
||||
? 'Исправления отправлены на повторную модерацию.'
|
||||
: 'Изменения сохранены.'
|
||||
);
|
||||
void router.push({ name: 'marketplace-my-offers' });
|
||||
} else {
|
||||
await createOffer(payload);
|
||||
@@ -806,7 +970,7 @@ async function prefillForEdit(id: string): Promise<void> {
|
||||
product_name: offer.product_name,
|
||||
description: offer.description ?? '',
|
||||
category_id: offer.category_id != null ? Number(offer.category_id) : null,
|
||||
price_per_unit: offer.price_per_unit,
|
||||
price_per_unit: formatPriceForInput(offer.price_per_unit),
|
||||
unit_of_measure: offer.unit_of_measure as MarketplaceUnitOfMeasure,
|
||||
quantity_available: offer.quantity_available,
|
||||
unlimited_flag: offer.unlimited_flag,
|
||||
@@ -817,9 +981,20 @@ async function prefillForEdit(id: string): Promise<void> {
|
||||
min_threshold: offer.min_threshold,
|
||||
warranty_days: offer.warranty_days,
|
||||
};
|
||||
existingImages.value = (offer.images ?? [])
|
||||
gallery.value = (offer.images ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((img) => ({
|
||||
uid: img.bucket_key,
|
||||
url: img.url,
|
||||
bucket_key: img.bucket_key,
|
||||
mime_type: img.mime_type,
|
||||
}));
|
||||
originalImageKeys.value = gallery.value.map((g) => g.bucket_key as string);
|
||||
coverIndex.value = 0;
|
||||
currentStatus.value = offer.status;
|
||||
currentCycleType.value = offer.cycle_type;
|
||||
rejectReason.value = offer.reject_reason ?? null;
|
||||
} catch (e) {
|
||||
FailAlert(e, 'Не удалось загрузить предложение');
|
||||
} finally {
|
||||
@@ -838,7 +1013,7 @@ onMounted(async () => {
|
||||
} else {
|
||||
// Восстанавливаем черновик и подключаем автосохранение (клиент-only).
|
||||
restoreDraft();
|
||||
watch([form, activeKey, completedKeys, imageDrafts, coverIndex], scheduleSaveDraft, {
|
||||
watch([form, activeKey, completedKeys, gallery, coverIndex], scheduleSaveDraft, {
|
||||
deep: true,
|
||||
});
|
||||
}
|
||||
@@ -846,7 +1021,7 @@ onMounted(async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
for (const d of imageDrafts.value) URL.revokeObjectURL(d.preview_url);
|
||||
for (const g of gallery.value) if (g.url.startsWith('blob:')) URL.revokeObjectURL(g.url);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -862,28 +1037,25 @@ onBeforeUnmount(() => {
|
||||
gap: var(--p-4, 16px);
|
||||
}
|
||||
|
||||
&__head {
|
||||
// Скелетон формы на время дозагрузки оферты (режим редактирования).
|
||||
&__skel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--p-2, 8px);
|
||||
gap: var(--p-4, 16px);
|
||||
padding: var(--p-4, 16px) 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: var(--p-fs-h2, 24px);
|
||||
font-weight: 600;
|
||||
color: var(--p-ink);
|
||||
}
|
||||
&__skel-title { width: 35%; }
|
||||
|
||||
&__intro {
|
||||
margin: 0;
|
||||
font-size: var(--p-fs-body-sm, 13px);
|
||||
line-height: var(--p-lh-body, 1.55);
|
||||
color: var(--p-ink-2);
|
||||
}
|
||||
&__skel-line { width: 100%; }
|
||||
&__skel-line--wide { width: 80%; }
|
||||
&__skel-line--narrow { width: 55%; }
|
||||
|
||||
&__notice {
|
||||
background: var(--p-warn-soft, #fff8e1);
|
||||
&__manage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--p-3, 12px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__step {
|
||||
@@ -1024,11 +1196,24 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
// Навигация формы прибита к низу экрана — «Отменить/Назад» и «Далее/
|
||||
// Сохранить» всегда на виду, без прокрутки до конца. Фон перекрывает контент,
|
||||
// уезжающий под бар при скролле.
|
||||
//
|
||||
// margin-bottom компенсирует нижний padding q-page (--p-6): без этого в конце
|
||||
// прокрутки бар «приземляется» на величину этого отступа выше, чем стоял
|
||||
// прижатым, и кажется, что он меняет высоту/дёргается. С компенсацией позиция
|
||||
// «прижат» совпадает с «в покое» — бар не двигается.
|
||||
&__foot {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--p-2, 8px);
|
||||
padding-top: var(--p-4, 16px);
|
||||
margin-bottom: calc(-1 * var(--p-6, 24px));
|
||||
padding: var(--p-3, 12px) 0;
|
||||
background: var(--p-canvas);
|
||||
border-top: 1px solid var(--p-line, #e0e0e0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,28 +40,14 @@ export async function fetchMyOffers(
|
||||
}
|
||||
|
||||
/**
|
||||
* Эпик 4 / Story 4.2: поставщик вручную запускает поставку по своему
|
||||
* предложению с открытой подпиской (cycle_type=open_subscription).
|
||||
*
|
||||
* Backend Resolver: marketplace-cycle.resolver.ts → marketplaceTriggerOpenSubscription
|
||||
* (guard 'Offer' 'update:own'). Нажатие = акцепт всего накопленного пула:
|
||||
* сервер формирует сводную заявку status=ACCEPTED и принимает заказы разом.
|
||||
* Ошибки backend (пустой пул, не open_subscription, не ACTIVE, чужой Offer)
|
||||
* приходят как GraphQL-исключения — пробрасываем их вызывающему компоненту.
|
||||
* Поставщик возвращает снятое предложение на публикацию (WITHDRAWN →
|
||||
* PENDING_MODERATION). Backend: marketplace-offer.resolver.ts →
|
||||
* marketplaceRepublishOffer (guard 'Offer' 'update:own'). Снятие не удаляет
|
||||
* данные оферты, поэтому пересоздавать ничего не нужно — она снова уходит на
|
||||
* модерацию с прежним содержимым.
|
||||
*/
|
||||
export async function triggerOpenSubscription(offer_id: string): Promise<void> {
|
||||
await client.Mutation(Mutations.Marketplace.TriggerOpenSubscription.mutation, {
|
||||
variables: { input: { offer_id } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Поставщик снимает своё предложение с публикации (статус → WITHDRAWN).
|
||||
* Backend: marketplace-offer.resolver.ts → marketplaceWithdrawOffer
|
||||
* (guard 'Offer' 'delete:own', ownership проверяется в сервисе).
|
||||
*/
|
||||
export async function withdrawOffer(id: string): Promise<void> {
|
||||
await client.Mutation(Mutations.Marketplace.WithdrawOffer.mutation, {
|
||||
export async function republishOffer(id: string): Promise<void> {
|
||||
await client.Mutation(Mutations.Marketplace.RepublishOffer.mutation, {
|
||||
variables: { input: { id } },
|
||||
});
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<template lang="pug">
|
||||
BaseButton(
|
||||
variant="primary",
|
||||
size="sm",
|
||||
:icon-only="isCompact",
|
||||
aria-label="Создать предложение",
|
||||
@click="goCreate"
|
||||
)
|
||||
template(#icon-left)
|
||||
q-icon(name="add", size="18px")
|
||||
| Создать предложение
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { BaseButton } from 'src/shared/ui/base';
|
||||
|
||||
/**
|
||||
* Кнопка «Создать предложение» для правого верхнего угла шапки (телепорт через
|
||||
* useHeaderActions со стола поставщика). На десктопе — полная кнопка с
|
||||
* подписью, на телефоне (xs) сворачивается до иконки `add`.
|
||||
*/
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const { info } = useSystemStore();
|
||||
|
||||
const isCompact = computed(() => $q.screen.lt.sm);
|
||||
|
||||
function goCreate(): void {
|
||||
void router.push({
|
||||
name: 'marketplace-create-offer',
|
||||
params: { coopname: info.coopname },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
+263
-336
@@ -1,78 +1,113 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Dialog, Notify } from 'quasar';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Queries } from '@coopenomics/sdk';
|
||||
import { client } from 'src/shared/api/client';
|
||||
import { LocalStorage, Notify } from 'quasar';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useHeaderActions } from 'src/shared/hooks';
|
||||
import { marketplaceUnitShort } from 'src/shared/lib/consts';
|
||||
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
|
||||
import { BaseButton, BaseCard, BaseInput, EmptyState } from 'src/shared/ui/base';
|
||||
import CreateOfferHeaderButton from './CreateOfferHeaderButton.vue';
|
||||
import {
|
||||
CatalogOfferCard,
|
||||
type CatalogOffer,
|
||||
type CatalogOfferStatus,
|
||||
} from 'src/widgets/Marketplace/CatalogOfferCard';
|
||||
import {
|
||||
OfferDetailsDialog,
|
||||
type OfferDetail,
|
||||
} from 'src/widgets/Marketplace/OfferDetailsDialog';
|
||||
import { fetchMyOffers, triggerOpenSubscription, withdrawOffer } from '../api';
|
||||
import type {
|
||||
MarketplaceOfferCycleTypeView,
|
||||
MarketplaceOfferStatusView,
|
||||
MarketplaceOfferView,
|
||||
} from '../types';
|
||||
import { fetchMyOffers, republishOffer } from '../api';
|
||||
import type { MarketplaceOfferStatusView, MarketplaceOfferView } from '../types';
|
||||
|
||||
/**
|
||||
* Эпик 3 / Story 3.4: offerer-стол «Мои предложения».
|
||||
*
|
||||
* Поставщик видит свои Offer'ы во всех 4 статусах: PENDING_MODERATION,
|
||||
* ACTIVE, REJECTED, WITHDRAWN. Канон `CatalogOfferCard` с цветными chip'ами
|
||||
* для статуса. Client-side фильтр + поиск по названию (backend
|
||||
* ACTIVE, REJECTED, WITHDRAWN. Канон `CatalogOfferCard` со статус-чипом.
|
||||
* Client-side фильтр по статусу + поиск по названию (backend
|
||||
* `marketplaceListMyOffers` принимает только пагинацию).
|
||||
*
|
||||
* Polling 30s — Offer'ы меняют статус через модерацию председателя
|
||||
* (`marketplaceApproveOffer` / `marketplaceRejectOffer`).
|
||||
* Клик по карточке/изображению редактируемой оферты ведёт сразу на страницу
|
||||
* редактирования (`marketplace-edit-offer`) — там же статус, кнопки «Снять с
|
||||
* публикации» и «Запустить поставку». Отдельного диалога-просмотра больше нет.
|
||||
*
|
||||
* Вёрстка по канону MONO Platform v2: инфо-баннер (`.banner`), дашборд-метрики
|
||||
* (BaseCard), полноширинный поиск и канон-меню фильтра статусов (`.tabbar`).
|
||||
* Фильтр deep-linkable через query `?status=` — на «На модерации» и т.п. можно
|
||||
* перейти прямой ссылкой.
|
||||
*
|
||||
* Polling 30s — Offer'ы меняют статус через модерацию председателя.
|
||||
* Подписанные URL картинок стабилизированы на бэкенде (окно getReadUrl),
|
||||
* поэтому при перезагрузке списка изображения не перекачиваются.
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
// Подсказку-баннер можно скрыть навсегда (запоминаем в LocalStorage).
|
||||
const BANNER_LS_KEY = 'mp:my-offers:banner-dismissed';
|
||||
// Кол-во скелетон-карточек на время первичной загрузки.
|
||||
const SKELETON_COUNT = 8;
|
||||
|
||||
// Открыть форму редактирования можно для активной, ожидающей модерации и
|
||||
// отклонённой оферты. Отклонённую правят, чтобы устранить причину и
|
||||
// переотправить на модерацию. Снятую (WITHDRAWN) backend на edit не пускает —
|
||||
// её сначала возвращают на публикацию кнопкой «Опубликовать снова».
|
||||
const EDITABLE_STATUSES: ReadonlyArray<MarketplaceOfferStatusView> = [
|
||||
'PENDING_MODERATION',
|
||||
'ACTIVE',
|
||||
'REJECTED',
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { info } = useSystemStore();
|
||||
const { registerAction } = useHeaderActions();
|
||||
|
||||
const items = ref<MarketplaceOfferView[]>([]);
|
||||
const totalCount = ref(0);
|
||||
const totalPages = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const loading = ref(false);
|
||||
const statusFilter = ref<MarketplaceOfferStatusView | null>(null);
|
||||
const search = ref('');
|
||||
// id предложений, по которым прямо сейчас идёт запуск поставки — блокируем
|
||||
// повторное нажатие кнопки до ответа backend.
|
||||
const triggeringIds = ref<Set<string>>(new Set());
|
||||
const withdrawingIds = ref<Set<string>>(new Set());
|
||||
// Читаем синхронно: если подсказку уже скрыли — она не появляется вообще,
|
||||
// без мигания на onMounted.
|
||||
const bannerDismissed = ref(LocalStorage.getItem(BANNER_LS_KEY) === true);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Справочник категорий (id → название) для детального просмотра.
|
||||
const categoryNames = ref<Record<number, string>>({});
|
||||
// Скелетон показываем только на первичной загрузке (список ещё пуст). При
|
||||
// polling'е данные обновляются молча — без дёргания спиннером.
|
||||
const showSkeleton = computed(() => loading.value && items.value.length === 0);
|
||||
|
||||
// Детальный просмотр предложения — клик по карточке открывает модалку
|
||||
// с полным описанием и действиями (редактировать / снять / запустить).
|
||||
const detailsOpen = ref(false);
|
||||
const selectedId = ref<string | null>(null);
|
||||
const selected = computed<MarketplaceOfferView | null>(
|
||||
() => items.value.find((o) => o.id === selectedId.value) ?? null,
|
||||
);
|
||||
function dismissBanner(): void {
|
||||
bannerDismissed.value = true;
|
||||
LocalStorage.set(BANNER_LS_KEY, true);
|
||||
}
|
||||
|
||||
const STATUS_FILTER_OPTIONS: Array<{ label: string; value: MarketplaceOfferStatusView | null }> = [
|
||||
{ label: 'Все', value: null },
|
||||
{ label: 'На модерации', value: 'PENDING_MODERATION' },
|
||||
{ label: 'Опубликованы', value: 'ACTIVE' },
|
||||
{ label: 'Отклонены', value: 'REJECTED' },
|
||||
{ label: 'Сняты', value: 'WITHDRAWN' },
|
||||
// Фильтр статусов = канон-меню `.tabbar`. `slug` — стабильный ключ в URL
|
||||
// (`?status=moderation`), чтобы на любой фильтр можно было перейти ссылкой.
|
||||
const STATUS_FILTER_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: MarketplaceOfferStatusView | null;
|
||||
slug: string;
|
||||
}> = [
|
||||
{ label: 'Все', value: null, slug: 'all' },
|
||||
{ label: 'На модерации', value: 'PENDING_MODERATION', slug: 'moderation' },
|
||||
{ label: 'Опубликовано', value: 'ACTIVE', slug: 'published' },
|
||||
{ label: 'Отклонено', value: 'REJECTED', slug: 'rejected' },
|
||||
{ label: 'Сняты', value: 'WITHDRAWN', slug: 'withdrawn' },
|
||||
];
|
||||
|
||||
function isActiveTab(value: MarketplaceOfferStatusView | null): boolean {
|
||||
return statusFilter.value === value;
|
||||
}
|
||||
|
||||
function setFilter(option: (typeof STATUS_FILTER_OPTIONS)[number]): void {
|
||||
if (statusFilter.value === option.value) return;
|
||||
statusFilter.value = option.value;
|
||||
const query = { ...route.query };
|
||||
if (option.value) query.status = option.slug;
|
||||
else delete query.status;
|
||||
void router.replace({ query });
|
||||
}
|
||||
|
||||
const STATUS_TO_CARD: Record<MarketplaceOfferStatusView, CatalogOfferStatus> = {
|
||||
PENDING_MODERATION: 'moderation',
|
||||
ACTIVE: 'published',
|
||||
@@ -92,7 +127,12 @@ const filtered = computed(() => {
|
||||
return list;
|
||||
});
|
||||
|
||||
const cards = computed<Array<CatalogOffer & { domainStatus: MarketplaceOfferStatusView; cycleType: MarketplaceOfferCycleTypeView; rejectReason: string | null }>>(() =>
|
||||
type OfferCard = CatalogOffer & {
|
||||
domainStatus: MarketplaceOfferStatusView;
|
||||
rejectReason: string | null;
|
||||
};
|
||||
|
||||
const cards = computed<OfferCard[]>(() =>
|
||||
filtered.value.map((o) => ({
|
||||
id: o.id,
|
||||
title: o.product_name,
|
||||
@@ -103,124 +143,56 @@ const cards = computed<Array<CatalogOffer & { domainStatus: MarketplaceOfferStat
|
||||
unitLabel: marketplaceUnitShort(o.unit_of_measure),
|
||||
status: STATUS_TO_CARD[o.status],
|
||||
domainStatus: o.status,
|
||||
cycleType: o.cycle_type,
|
||||
rejectReason: o.reject_reason ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
const selectedDetail = computed<OfferDetail | null>(() => {
|
||||
const o = selected.value;
|
||||
if (!o) return null;
|
||||
const catId = o.category_id != null ? Number(o.category_id) : null;
|
||||
return {
|
||||
id: o.id,
|
||||
title: o.product_name,
|
||||
description: o.description ?? null,
|
||||
status: STATUS_TO_CARD[o.status],
|
||||
unitCost: parseFloat(o.price_per_unit) || 0,
|
||||
unitLabel: marketplaceUnitShort(o.unit_of_measure),
|
||||
remainUnits: o.unlimited_flag ? undefined : o.quantity_available - o.quantity_blocked,
|
||||
unlimited: o.unlimited_flag,
|
||||
categoryName: catId != null ? categoryNames.value[catId] ?? null : null,
|
||||
cycleType: o.cycle_type,
|
||||
warrantyDays: o.warranty_days,
|
||||
rejectReason: o.reject_reason,
|
||||
};
|
||||
});
|
||||
|
||||
// Поставщик может редактировать предложение во всех статусах кроме снятого.
|
||||
// Любая правка на бэкенде сбрасывает статус в PENDING_MODERATION (повторная
|
||||
// модерация в рамках того же предложения) — поэтому редактирование доступно
|
||||
// и на модерации, и у опубликованного, и у отклонённого.
|
||||
const canEdit = computed(
|
||||
() => !!selected.value && selected.value.status !== 'WITHDRAWN',
|
||||
);
|
||||
const canWithdraw = computed(
|
||||
() =>
|
||||
!!selected.value &&
|
||||
(selected.value.status === 'PENDING_MODERATION' || selected.value.status === 'ACTIVE'),
|
||||
);
|
||||
const canTrigger = computed(
|
||||
() =>
|
||||
!!selected.value &&
|
||||
selected.value.status === 'ACTIVE' &&
|
||||
selected.value.cycle_type === 'open_subscription',
|
||||
);
|
||||
|
||||
function openDetails(id: string | number | undefined): void {
|
||||
if (id == null) return;
|
||||
selectedId.value = String(id);
|
||||
detailsOpen.value = true;
|
||||
}
|
||||
|
||||
function goEdit(): void {
|
||||
const o = selected.value;
|
||||
if (!o) return;
|
||||
detailsOpen.value = false;
|
||||
void router.push({
|
||||
name: 'marketplace-edit-offer',
|
||||
params: { coopname: info.coopname, offerId: o.id },
|
||||
});
|
||||
}
|
||||
|
||||
function onWithdraw(): void {
|
||||
const o = selected.value;
|
||||
if (!o) return;
|
||||
Dialog.create({
|
||||
title: 'Снять предложение с публикации?',
|
||||
message:
|
||||
`Предложение «${o.product_name}» перестанет показываться в каталоге и не будет ` +
|
||||
'принимать новые заказы. Это действие можно отменить, только создав предложение заново.',
|
||||
cancel: { label: 'Отмена', flat: true, noCaps: true },
|
||||
ok: { label: 'Снять с публикации', color: 'negative', unelevated: true, noCaps: true },
|
||||
persistent: true,
|
||||
}).onOk(async () => {
|
||||
withdrawingIds.value = new Set(withdrawingIds.value).add(o.id);
|
||||
try {
|
||||
await withdrawOffer(o.id);
|
||||
detailsOpen.value = false;
|
||||
Notify.create({ type: 'positive', message: 'Предложение снято с публикации.' });
|
||||
await load(1, false);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
Notify.create({ type: 'negative', message });
|
||||
} finally {
|
||||
const next = new Set(withdrawingIds.value);
|
||||
next.delete(o.id);
|
||||
withdrawingIds.value = next;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCategories(): Promise<void> {
|
||||
try {
|
||||
const { [Queries.Marketplace.ListCategories.name]: cats } = await client.Query(
|
||||
Queries.Marketplace.ListCategories.query,
|
||||
);
|
||||
const map: Record<number, string> = {};
|
||||
for (const c of cats ?? []) map[Number(c.id)] = c.display_name;
|
||||
categoryNames.value = map;
|
||||
} catch {
|
||||
// Справочник категорий не критичен — просто не покажем название.
|
||||
}
|
||||
}
|
||||
|
||||
const counts = computed(() => {
|
||||
const counters = computed(() => {
|
||||
const total = items.value.length;
|
||||
const active = items.value.filter((o) => o.status === 'ACTIVE').length;
|
||||
const pending = items.value.filter((o) => o.status === 'PENDING_MODERATION').length;
|
||||
const rejected = items.value.filter((o) => o.status === 'REJECTED').length;
|
||||
return { total, active, pending, rejected };
|
||||
return [
|
||||
{ label: 'Всего', value: total, cls: '' },
|
||||
{ label: 'Опубликовано', value: active, cls: 'text-positive' },
|
||||
{ label: 'На модерации', value: pending, cls: 'text-warning' },
|
||||
{ label: 'Отклонено', value: rejected, cls: 'text-negative' },
|
||||
];
|
||||
});
|
||||
|
||||
const hasMore = computed(() => currentPage.value < totalPages.value);
|
||||
|
||||
function goCard(card: OfferCard): void {
|
||||
if (!EDITABLE_STATUSES.includes(card.domainStatus)) return;
|
||||
void router.push({
|
||||
name: 'marketplace-edit-offer',
|
||||
params: { coopname: info.coopname, offerId: String(card.id) },
|
||||
});
|
||||
}
|
||||
|
||||
const republishing = ref<string | null>(null);
|
||||
|
||||
// Снятое предложение возвращается на публикацию без пересоздания — backend
|
||||
// просто меняет статус на PENDING_MODERATION, данные оферты сохранены.
|
||||
async function onRepublish(card: OfferCard): Promise<void> {
|
||||
republishing.value = String(card.id);
|
||||
try {
|
||||
await republishOffer(String(card.id));
|
||||
Notify.create({ type: 'positive', message: 'Предложение возвращено на модерацию.' });
|
||||
await load(1, false);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
Notify.create({ type: 'negative', message });
|
||||
} finally {
|
||||
republishing.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(page: number, append: boolean): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await fetchMyOffers({ page, limit: PAGE_SIZE });
|
||||
items.value = append ? [...items.value, ...result.items] : result.items;
|
||||
totalCount.value = result.totalCount;
|
||||
totalPages.value = result.totalPages;
|
||||
currentPage.value = result.currentPage;
|
||||
} catch (e) {
|
||||
@@ -237,48 +209,21 @@ function loadMore(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Эпик 4 / Story 4.2: ручной запуск поставки по предложению с открытой
|
||||
* подпиской. Доступен только для ACTIVE-предложений с cycle_type=open_subscription.
|
||||
* Нажатие = акцепт всего накопленного пула заказов, действие необратимо —
|
||||
* поэтому подтверждаем через диалог. После успеха перезагружаем список
|
||||
* (остатки и счётчики меняются по итогам формирования партии).
|
||||
*/
|
||||
function triggerSupply(offerId: string, title: string): void {
|
||||
Dialog.create({
|
||||
title: 'Запустить поставку?',
|
||||
message:
|
||||
`Все накопленные заказы по предложению «${title}» будут разом зафиксированы в одну ` +
|
||||
'партию и приняты к поставке. Отменить запуск нельзя.',
|
||||
cancel: { label: 'Отмена', flat: true, noCaps: true },
|
||||
ok: { label: 'Запустить поставку', color: 'primary', unelevated: true, noCaps: true },
|
||||
persistent: true,
|
||||
}).onOk(() => {
|
||||
void runTrigger(offerId);
|
||||
});
|
||||
}
|
||||
|
||||
async function runTrigger(offerId: string): Promise<void> {
|
||||
triggeringIds.value = new Set(triggeringIds.value).add(offerId);
|
||||
try {
|
||||
await triggerOpenSubscription(offerId);
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Поставка запущена: заказы зафиксированы в партию и приняты.',
|
||||
});
|
||||
await load(1, false);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
Notify.create({ type: 'negative', message });
|
||||
} finally {
|
||||
const next = new Set(triggeringIds.value);
|
||||
next.delete(offerId);
|
||||
triggeringIds.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([load(1, false), loadCategories()]);
|
||||
// Канон: «Создать предложение» живёт в правом верхнем углу шапки (телепорт),
|
||||
// а не в меню. useHeaderActions сам снимет кнопку при уходе со страницы.
|
||||
registerAction({
|
||||
id: 'marketplace-create-offer',
|
||||
component: CreateOfferHeaderButton,
|
||||
order: 10,
|
||||
});
|
||||
|
||||
// Восстанавливаем фильтр из URL — поддержка прямых ссылок на статус.
|
||||
const slug = typeof route.query.status === 'string' ? route.query.status : null;
|
||||
const fromUrl = STATUS_FILTER_OPTIONS.find((o) => o.slug === slug);
|
||||
if (fromUrl) statusFilter.value = fromUrl.value;
|
||||
|
||||
await load(1, false);
|
||||
pollTimer = setInterval(() => {
|
||||
void load(1, false);
|
||||
}, POLL_INTERVAL_MS);
|
||||
@@ -293,187 +238,169 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
q-page.mp-role-offerer.mp-my-offers(role="region", aria-label="Мои предложения")
|
||||
div.mp-my-offers__header
|
||||
div
|
||||
div.text-h5 Мои предложения
|
||||
div.text-caption.mp-my-offers__subtitle
|
||||
| Все ваши предложения в кооперативе: на модерации, опубликованные, отклонённые и снятые. Чтобы опубликовать новое — перейдите на «Создать предложение».
|
||||
q-space
|
||||
q-btn(flat, dense, round, icon="fa-solid fa-rotate", :loading="loading", @click="load(1, false)", aria-label="Обновить")
|
||||
|
||||
div.row.q-col-gutter-md.mp-my-offers__counters
|
||||
div.col-6.col-md-3
|
||||
q-card(flat, bordered)
|
||||
q-card-section
|
||||
div.text-caption Всего
|
||||
div.text-h6 {{ counts.total }}
|
||||
div.col-6.col-md-3
|
||||
q-card(flat, bordered)
|
||||
q-card-section
|
||||
div.text-caption Активных
|
||||
div.text-h6.text-positive {{ counts.active }}
|
||||
div.col-6.col-md-3
|
||||
q-card(flat, bordered)
|
||||
q-card-section
|
||||
div.text-caption На модерации
|
||||
div.text-h6.text-warning {{ counts.pending }}
|
||||
div.col-6.col-md-3
|
||||
q-card(flat, bordered)
|
||||
q-card-section
|
||||
div.text-caption Отклонены
|
||||
div.text-h6.text-negative {{ counts.rejected }}
|
||||
|
||||
div.row.q-col-gutter-md.items-center
|
||||
div.col-12.col-md-6
|
||||
q-input(
|
||||
v-model="search",
|
||||
outlined,
|
||||
dense,
|
||||
clearable,
|
||||
placeholder="Поиск по названию",
|
||||
debounce="200"
|
||||
)
|
||||
template(#prepend)
|
||||
q-icon(name="fa-solid fa-magnifying-glass")
|
||||
div.col-12.col-md-6
|
||||
q-btn-toggle(
|
||||
v-model="statusFilter",
|
||||
:options="STATUS_FILTER_OPTIONS",
|
||||
no-caps,
|
||||
spread,
|
||||
unelevated,
|
||||
toggle-color="primary"
|
||||
q-page.my-offers(role="region", aria-label="Мои предложения")
|
||||
.my-offers__col
|
||||
.banner.banner--info(v-if="!bannerDismissed")
|
||||
q-icon.banner__icon(name="info", size="18px")
|
||||
.banner__body
|
||||
| Все ваши предложения в кооперативе и их статус. Нажмите на карточку,
|
||||
| чтобы открыть предложение — изменить цену и остаток, отредактировать
|
||||
| описание или снять с публикации. Цена и количество меняются без
|
||||
| повторной модерации.
|
||||
BaseButton.my-offers__banner-close(
|
||||
variant="ghost",
|
||||
icon-only,
|
||||
size="sm",
|
||||
aria-label="Скрыть подсказку",
|
||||
@click="dismissBanner"
|
||||
)
|
||||
template(#icon-left)
|
||||
q-icon(name="close", size="16px")
|
||||
|
||||
q-inner-loading(:showing="loading && items.length === 0")
|
||||
q-spinner(color="primary", size="2em")
|
||||
.row.q-col-gutter-md
|
||||
.col-6.col-md-3(v-for="kpi in counters", :key="kpi.label")
|
||||
BaseCard
|
||||
.text-caption.text-grey-7 {{ kpi.label }}
|
||||
.text-h5(:class="kpi.cls") {{ kpi.value }}
|
||||
|
||||
div.mp-my-offers__empty(v-if="!loading && filtered.length === 0")
|
||||
q-icon(name="fa-solid fa-clipboard-list", size="48px", color="grey-5")
|
||||
div.text-subtitle1.q-mt-md Нет предложений в этом фильтре
|
||||
div.text-caption Если у вас нет ни одного предложения — создайте первое на странице «Создать предложение».
|
||||
BaseInput(v-model="search", placeholder="Поиск по названию", clearable)
|
||||
template(#prepend)
|
||||
q-icon(name="search")
|
||||
|
||||
div.mp-my-offers__hint(v-if="filtered.length > 0")
|
||||
q-icon(name="fa-regular fa-hand-pointer", size="14px")
|
||||
| Нажмите на карточку, чтобы открыть, отредактировать или снять предложение.
|
||||
nav.tabbar.my-offers__tabs
|
||||
.tabbar__tabs
|
||||
button.tab(
|
||||
v-for="opt in STATUS_FILTER_OPTIONS",
|
||||
:key="opt.slug",
|
||||
type="button",
|
||||
:class="{ 'tab--active': isActiveTab(opt.value) }",
|
||||
@click="setFilter(opt)"
|
||||
)
|
||||
span {{ opt.label }}
|
||||
.tabbar__actions
|
||||
BaseButton(
|
||||
variant="ghost",
|
||||
icon-only,
|
||||
aria-label="Обновить",
|
||||
:loading="loading",
|
||||
@click="load(1, false)"
|
||||
)
|
||||
template(#icon-left)
|
||||
q-icon(name="refresh", size="20px")
|
||||
|
||||
div.mp-my-offers__grid(v-if="filtered.length > 0")
|
||||
div.mp-my-offers__cell(
|
||||
v-for="card in cards",
|
||||
:key="card.id"
|
||||
//- Скелетон вместо спиннера: каркас карточек проявляется сразу, без
|
||||
//- дёргания. Только на первичной загрузке — polling обновляет молча.
|
||||
.row.q-col-gutter-md(v-if="showSkeleton")
|
||||
.col-12.col-sm-6.col-md-4.col-lg-3(v-for="n in SKELETON_COUNT", :key="`skel-${n}`")
|
||||
.my-offers__skel
|
||||
.skel.my-offers__skel-media
|
||||
.skel.skel--title.my-offers__skel-line.my-offers__skel-line--title
|
||||
.skel.skel--num.my-offers__skel-line.my-offers__skel-line--price
|
||||
.skel.skel--text.my-offers__skel-line.my-offers__skel-line--cat
|
||||
|
||||
EmptyState(
|
||||
v-if="!loading && filtered.length === 0",
|
||||
title="Нет предложений в этом фильтре",
|
||||
body="Если у вас нет ни одного предложения — создайте первое на странице «Создать предложение»."
|
||||
)
|
||||
CatalogOfferCard(:offer="card", @click="openDetails(card.id)")
|
||||
div.mp-my-offers__reason(v-if="card.domainStatus === 'REJECTED' && card.rejectReason")
|
||||
q-icon(name="fa-solid fa-circle-exclamation", color="negative", size="14px")
|
||||
| {{ card.rejectReason }}
|
||||
|
||||
OfferDetailsDialog(v-model="detailsOpen", :offer="selectedDetail")
|
||||
template(v-if="selected", v-slot:actions)
|
||||
q-btn(
|
||||
v-if="canWithdraw",
|
||||
flat,
|
||||
no-caps,
|
||||
color="negative",
|
||||
icon="fa-solid fa-eye-slash",
|
||||
label="Снять",
|
||||
:loading="withdrawingIds.has(selected.id)",
|
||||
@click="onWithdraw"
|
||||
)
|
||||
q-btn(
|
||||
v-if="canEdit",
|
||||
flat,
|
||||
no-caps,
|
||||
color="primary",
|
||||
icon="fa-solid fa-pen",
|
||||
label="Редактировать",
|
||||
@click="goEdit"
|
||||
)
|
||||
q-btn(
|
||||
v-if="canTrigger",
|
||||
unelevated,
|
||||
no-caps,
|
||||
color="primary",
|
||||
icon="fa-solid fa-truck-fast",
|
||||
label="Запустить поставку",
|
||||
:loading="triggeringIds.has(selected.id)",
|
||||
@click="triggerSupply(selected.id, selected.product_name)"
|
||||
)
|
||||
template(v-if="filtered.length > 0")
|
||||
.row.q-col-gutter-md
|
||||
.col-12.col-sm-6.col-md-4.col-lg-3(v-for="card in cards", :key="card.id")
|
||||
CatalogOfferCard(:offer="card", @click="goCard(card)")
|
||||
.my-offers__reason(v-if="card.domainStatus === 'REJECTED' && card.rejectReason")
|
||||
q-icon(name="error", color="negative", size="16px")
|
||||
span {{ card.rejectReason }}
|
||||
.my-offers__action(v-if="card.domainStatus === 'WITHDRAWN'")
|
||||
BaseButton(
|
||||
variant="secondary",
|
||||
size="sm",
|
||||
block,
|
||||
:loading="republishing === String(card.id)",
|
||||
@click="onRepublish(card)"
|
||||
)
|
||||
template(#icon-left)
|
||||
q-icon(name="publish", size="16px")
|
||||
| Опубликовать снова
|
||||
|
||||
div.mp-my-offers__more(v-if="hasMore")
|
||||
q-btn(
|
||||
flat,
|
||||
no-caps,
|
||||
:loading="loading",
|
||||
label="Показать ещё",
|
||||
@click="loadMore"
|
||||
)
|
||||
.my-offers__more(v-if="hasMore")
|
||||
BaseButton(variant="ghost", :loading="loading", @click="loadMore") Показать ещё
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mp-my-offers {
|
||||
padding: var(--mp-space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mp-space-md);
|
||||
.my-offers {
|
||||
padding: var(--p-6, 24px) var(--p-4, 16px);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--mp-space-md);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: var(--mp-on-surface-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
&__counters {
|
||||
margin-bottom: var(--mp-space-xs);
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--mp-space-md);
|
||||
}
|
||||
|
||||
&__cell {
|
||||
&__col {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mp-space-xs);
|
||||
gap: var(--p-4, 16px);
|
||||
}
|
||||
|
||||
// Канон-`.tabbar` рассчитан на полноширинную под-навигацию с большим
|
||||
// боковым padding'ом; внутри центрированной колонки выравниваем табы по её
|
||||
// краю, сохраняя нижнюю границу и активный underline.
|
||||
&__tabs {
|
||||
:deep(.tabbar__tabs) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.tabbar__actions) {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Крестик скрытия подсказки — прижат к правому краю баннера.
|
||||
&__banner-close {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin: -4px -4px 0 0;
|
||||
}
|
||||
|
||||
// Скелетон-карточка повторяет форму CatalogOfferCard: медиа сверху,
|
||||
// под ней — название, цена и категория.
|
||||
&__skel {
|
||||
border: 1px solid var(--p-line);
|
||||
border-radius: var(--p-r-md, 12px);
|
||||
overflow: hidden;
|
||||
padding-bottom: var(--p-3, 12px);
|
||||
}
|
||||
|
||||
&__skel-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&__skel-line {
|
||||
margin-top: var(--p-3, 12px);
|
||||
margin-left: var(--p-3, 12px);
|
||||
margin-right: var(--p-3, 12px);
|
||||
}
|
||||
|
||||
&__skel-line--title { width: 70%; }
|
||||
&__skel-line--price { width: 40%; }
|
||||
&__skel-line--cat { width: 85%; }
|
||||
|
||||
&__reason {
|
||||
display: flex;
|
||||
gap: var(--mp-space-xs);
|
||||
align-items: center;
|
||||
color: var(--mp-on-surface-muted);
|
||||
font-size: 12px;
|
||||
padding: 0 var(--mp-space-xs);
|
||||
gap: 6px;
|
||||
margin-top: var(--p-2, 8px);
|
||||
font-size: var(--p-fs-meta, 12px);
|
||||
line-height: 1.4;
|
||||
color: var(--p-ink-2);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mp-space-xs);
|
||||
color: var(--mp-on-surface-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--mp-space-xl) 0;
|
||||
color: var(--mp-on-surface-muted);
|
||||
&__action {
|
||||
margin-top: var(--p-2, 8px);
|
||||
}
|
||||
|
||||
&__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--mp-space-md) 0;
|
||||
padding: var(--p-4, 16px) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,11 +22,15 @@
|
||||
:name="i"
|
||||
class="mp-catalog-offer-card__slide"
|
||||
>
|
||||
<q-img :src="img" :alt="offer.title" fit="cover" />
|
||||
<!-- no-spinner: подписанный URL стабилизирован на бэкенде (окно
|
||||
getReadUrl), src не меняется при polling — лоудер только мигал бы.
|
||||
Клик по изображению = клик по карточке (стрелки карусели гасит
|
||||
@click.stop на самой ленте). -->
|
||||
<q-img :src="img" :alt="offer.title" fit="cover" no-spinner @click="onClick" />
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
<div v-else class="mp-catalog-offer-card__placeholder">
|
||||
<q-icon name="fa-solid fa-image" size="48px" color="grey-5" />
|
||||
<q-icon name="image" size="48px" color="grey-5" />
|
||||
</div>
|
||||
<span
|
||||
v-if="status"
|
||||
|
||||
@@ -70,3 +70,5 @@ export * as RejectOffer from './rejectOffer'
|
||||
export * as UpdateOffer from './updateOffer'
|
||||
/** Поставщик отзывает свой offer (любой статус, кроме APPROVED/REJECTED) */
|
||||
export * as WithdrawOffer from './withdrawOffer'
|
||||
/** Поставщик возвращает снятый offer на публикацию (WITHDRAWN → PENDING_MODERATION) */
|
||||
export * as RepublishOffer from './republishOffer'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { marketplaceOfferSelector } from '../../selectors/marketplace/offerSelector'
|
||||
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
|
||||
|
||||
export const name = 'marketplaceRepublishOffer'
|
||||
|
||||
export const mutation = Selector('Mutation')({
|
||||
[name]: [{ input: $('input', 'MarketplaceRepublishOfferInput!') }, marketplaceOfferSelector],
|
||||
})
|
||||
|
||||
export interface IInput {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
[key: string]: unknown
|
||||
|
||||
input: ModelTypes['MarketplaceRepublishOfferInput']
|
||||
}
|
||||
|
||||
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
|
||||
@@ -33,6 +33,7 @@ const rawOfferSelector = {
|
||||
updated_at: true,
|
||||
images: {
|
||||
url: true,
|
||||
bucket_key: true,
|
||||
mime_type: true,
|
||||
sort_order: true,
|
||||
is_cover: true,
|
||||
|
||||
@@ -802,6 +802,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
MarketplaceRemoveFromWhitelistInput:{
|
||||
|
||||
},
|
||||
MarketplaceRepublishOfferInput:{
|
||||
|
||||
},
|
||||
MarketplaceReturnClaimDefectCategory: "enum" as const,
|
||||
MarketplaceReturnClaimExpectedResolution: "enum" as const,
|
||||
@@ -1411,6 +1414,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
marketplaceReplaceAvailableItems:{
|
||||
input:"ReplaceAvailableItemsInput"
|
||||
},
|
||||
marketplaceRepublishOffer:{
|
||||
input:"MarketplaceRepublishOfferInput"
|
||||
},
|
||||
marketplaceRetryKUGeocode:{
|
||||
|
||||
},
|
||||
@@ -4154,6 +4160,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
warranty_days:"Int"
|
||||
},
|
||||
MarketplaceOfferImage:{
|
||||
bucket_key:"String",
|
||||
is_cover:"Boolean",
|
||||
mime_type:"String",
|
||||
sort_order:"Int",
|
||||
@@ -4761,6 +4768,7 @@ export const ReturnTypes: Record<string,any> = {
|
||||
marketplaceRemoveAvailableCategoryTypes:"Boolean",
|
||||
marketplaceRemoveFromWhitelist:"Boolean",
|
||||
marketplaceReplaceAvailableItems:"MarketplaceAvailableCategory",
|
||||
marketplaceRepublishOffer:"MarketplaceOffer",
|
||||
marketplaceRetryKUGeocode:"MarketplaceKUDetails",
|
||||
marketplaceSetKUStatus:"MarketplaceKUDetails",
|
||||
marketplaceSignAplReceptionAsChairman:"MarketplaceAplReceptionResult",
|
||||
|
||||
@@ -7005,6 +7005,8 @@ export type ValueTypes = {
|
||||
['...on MarketplaceOffer']?: Omit<ValueTypes["MarketplaceOffer"], "...on MarketplaceOffer">
|
||||
}>;
|
||||
["MarketplaceOfferImage"]: AliasType<{
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key?:boolean | `@${string}`,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover?:boolean | `@${string}`,
|
||||
/** MIME-тип изображения. */
|
||||
@@ -7017,10 +7019,12 @@ export type ValueTypes = {
|
||||
['...on MarketplaceOfferImage']?: Omit<ValueTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
|
||||
}>;
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string | Variable<any, string>,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string | Variable<any, string>
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null | Variable<any, string>,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null | Variable<any, string>,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null | Variable<any, string>
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: AliasType<{
|
||||
/** Текущая страница */
|
||||
@@ -7255,6 +7259,9 @@ export type ValueTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string | Variable<any, string>
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string | Variable<any, string>
|
||||
};
|
||||
["MarketplaceRequest"]: AliasType<{
|
||||
/** Артикул товара */
|
||||
@@ -8245,6 +8252,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ValueTypes["RemoveAvailableCate
|
||||
marketplaceRemoveAvailableCategoryTypes?: [{ input: ValueTypes["RemoveAvailableCategoryTypesInput"] | Variable<any, string>},boolean | `@${string}`],
|
||||
marketplaceRemoveFromWhitelist?: [{ input: ValueTypes["MarketplaceRemoveFromWhitelistInput"] | Variable<any, string>},boolean | `@${string}`],
|
||||
marketplaceReplaceAvailableItems?: [{ input: ValueTypes["ReplaceAvailableItemsInput"] | Variable<any, string>},ValueTypes["MarketplaceAvailableCategory"]],
|
||||
marketplaceRepublishOffer?: [{ input: ValueTypes["MarketplaceRepublishOfferInput"] | Variable<any, string>},ValueTypes["MarketplaceOffer"]],
|
||||
marketplaceRetryKUGeocode?: [{ coopname: string | Variable<any, string>, coreBraname: string | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSetKUStatus?: [{ data: ValueTypes["MarketplaceSetKUStatusInput"] | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSignAplReceptionAsChairman?: [{ data: ValueTypes["MarketplaceSignAplReceptionInput"] | Variable<any, string>},ValueTypes["MarketplaceAplReceptionResult"]],
|
||||
@@ -17030,6 +17038,8 @@ export type ResolverInputTypes = {
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["MarketplaceOfferImage"]: AliasType<{
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key?:boolean | `@${string}`,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover?:boolean | `@${string}`,
|
||||
/** MIME-тип изображения. */
|
||||
@@ -17041,10 +17051,12 @@ export type ResolverInputTypes = {
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: AliasType<{
|
||||
/** Текущая страница */
|
||||
@@ -17270,6 +17282,9 @@ export type ResolverInputTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string
|
||||
};
|
||||
["MarketplaceRequest"]: AliasType<{
|
||||
/** Артикул товара */
|
||||
@@ -18232,6 +18247,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ResolverInputTypes["RemoveAvail
|
||||
marketplaceRemoveAvailableCategoryTypes?: [{ input: ResolverInputTypes["RemoveAvailableCategoryTypesInput"]},boolean | `@${string}`],
|
||||
marketplaceRemoveFromWhitelist?: [{ input: ResolverInputTypes["MarketplaceRemoveFromWhitelistInput"]},boolean | `@${string}`],
|
||||
marketplaceReplaceAvailableItems?: [{ input: ResolverInputTypes["ReplaceAvailableItemsInput"]},ResolverInputTypes["MarketplaceAvailableCategory"]],
|
||||
marketplaceRepublishOffer?: [{ input: ResolverInputTypes["MarketplaceRepublishOfferInput"]},ResolverInputTypes["MarketplaceOffer"]],
|
||||
marketplaceRetryKUGeocode?: [{ coopname: string, coreBraname: string},ResolverInputTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSetKUStatus?: [{ data: ResolverInputTypes["MarketplaceSetKUStatusInput"]},ResolverInputTypes["MarketplaceKUDetails"]],
|
||||
marketplaceSignAplReceptionAsChairman?: [{ data: ResolverInputTypes["MarketplaceSignAplReceptionInput"]},ResolverInputTypes["MarketplaceAplReceptionResult"]],
|
||||
@@ -26730,7 +26746,9 @@ export type ModelTypes = {
|
||||
warranty_days: number
|
||||
};
|
||||
["MarketplaceOfferImage"]: {
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key: string,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover: boolean,
|
||||
/** MIME-тип изображения. */
|
||||
mime_type: string,
|
||||
@@ -26740,10 +26758,12 @@ export type ModelTypes = {
|
||||
url: string
|
||||
};
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: {
|
||||
/** Текущая страница */
|
||||
@@ -26956,6 +26976,9 @@ export type ModelTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string
|
||||
};
|
||||
["MarketplaceRequest"]: {
|
||||
/** Артикул товара */
|
||||
@@ -28320,6 +28343,8 @@ export type ModelTypes = {
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
marketplaceReplaceAvailableItems: Array<ModelTypes["MarketplaceAvailableCategory"]>,
|
||||
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
|
||||
marketplaceRepublishOffer: ModelTypes["MarketplaceOffer"],
|
||||
/** Повторно запускает геокодинг адреса ПВЗ.
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
@@ -37365,6 +37390,8 @@ export type GraphQLTypes = {
|
||||
};
|
||||
["MarketplaceOfferImage"]: {
|
||||
__typename: "MarketplaceOfferImage",
|
||||
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
|
||||
bucket_key: string,
|
||||
/** Является ли изображение обложкой карточки. */
|
||||
is_cover: boolean,
|
||||
/** MIME-тип изображения. */
|
||||
@@ -37376,10 +37403,12 @@ export type GraphQLTypes = {
|
||||
['...on MarketplaceOfferImage']: Omit<GraphQLTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
|
||||
};
|
||||
["MarketplaceOfferImageUploadInput"]: {
|
||||
/** Содержимое изображения, закодированное в base64. */
|
||||
base64: string,
|
||||
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type: string
|
||||
/** Содержимое нового изображения в base64. */
|
||||
base64?: string | undefined | null,
|
||||
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
|
||||
bucket_key?: string | undefined | null,
|
||||
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
|
||||
mime_type?: string | undefined | null
|
||||
};
|
||||
["MarketplaceOfferPaginationResult"]: {
|
||||
__typename: "MarketplaceOfferPaginationResult",
|
||||
@@ -37614,6 +37643,9 @@ export type GraphQLTypes = {
|
||||
["MarketplaceRemoveFromWhitelistInput"]: {
|
||||
/** eosio::name пайщика-поставщика */
|
||||
member_account: string
|
||||
};
|
||||
["MarketplaceRepublishOfferInput"]: {
|
||||
id: string
|
||||
};
|
||||
["MarketplaceRequest"]: {
|
||||
__typename: "MarketplaceRequest",
|
||||
@@ -39042,6 +39074,8 @@ export type GraphQLTypes = {
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
marketplaceReplaceAvailableItems: Array<GraphQLTypes["MarketplaceAvailableCategory"]>,
|
||||
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
|
||||
marketplaceRepublishOffer: GraphQLTypes["MarketplaceOffer"],
|
||||
/** Повторно запускает геокодинг адреса ПВЗ.
|
||||
|
||||
Требуемые роли: chairman. */
|
||||
@@ -43278,6 +43312,7 @@ type ZEUS_VARIABLES = {
|
||||
["MarketplaceRejectReturnAtVisitInput"]: ValueTypes["MarketplaceRejectReturnAtVisitInput"];
|
||||
["MarketplaceRejectReturnRemoteInput"]: ValueTypes["MarketplaceRejectReturnRemoteInput"];
|
||||
["MarketplaceRemoveFromWhitelistInput"]: ValueTypes["MarketplaceRemoveFromWhitelistInput"];
|
||||
["MarketplaceRepublishOfferInput"]: ValueTypes["MarketplaceRepublishOfferInput"];
|
||||
["MarketplaceReturnClaimDefectCategory"]: ValueTypes["MarketplaceReturnClaimDefectCategory"];
|
||||
["MarketplaceReturnClaimExpectedResolution"]: ValueTypes["MarketplaceReturnClaimExpectedResolution"];
|
||||
["MarketplaceReturnClaimPhotoUploadInput"]: ValueTypes["MarketplaceReturnClaimPhotoUploadInput"];
|
||||
|
||||
Reference in New Issue
Block a user