diff --git a/src/hooks/quotes/useQuotes.ts b/src/hooks/quotes/useQuotes.ts index 62ec13f67..f459e5a91 100644 --- a/src/hooks/quotes/useQuotes.ts +++ b/src/hooks/quotes/useQuotes.ts @@ -8,7 +8,7 @@ import { createClientLogger } from '@/lib/telemetry/structuredLogger'; import { toast } from 'sonner'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { quoteService } from '@/services/quoteService'; -import type { Quote, QuoteItem, PersonalizationTechnique } from "@/hooks/quotes/quoteTypes"; +import type { Quote, QuoteItem } from '@/hooks/quotes/quoteTypes'; import { supabase } from '@/integrations/supabase/client'; export type { @@ -16,64 +16,88 @@ export type { QuoteItem, QuoteItemPersonalization, PersonalizationTechnique, -} from "@/hooks/quotes/quoteTypes"; +} from '@/hooks/quotes/quoteTypes'; + +type QuoteHistoryOptions = { + fieldChanged?: string; + oldValue?: unknown; + newValue?: unknown; + metadata?: Record; +}; + +type QuoteSyncResponse = { + error?: string; + bitrix_deal_id?: string | number | null; + success?: boolean; +}; + +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : 'Erro desconhecido'; +} export function useQuotes() { const { user } = useAuth(); + const userId = user?.id ?? null; const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || null; const scope = useSalesScope(); const queryClient = useQueryClient(); // Queries - const { - data: quotes = [], - isLoading, - error, - refetch: fetchQuotes + const { + data: quotes = [], + isLoading, + error, + refetch: fetchQuotes, } = useQuery({ - queryKey: ['quotes', user?.id, scope], - queryFn: () => quoteService.fetchQuotes(user!.id, scope), - enabled: !!user, + queryKey: ['quotes', userId, scope], + queryFn: () => quoteService.fetchQuotes(userId ?? '', scope), + enabled: !!userId, }); - const { - data: techniques = [], - refetch: fetchTechniques - } = useQuery({ + const { data: techniques = [], refetch: fetchTechniques } = useQuery({ queryKey: ['techniques'], queryFn: () => quoteService.fetchTechniques(), - enabled: !!user, + enabled: !!userId, staleTime: 60 * 60 * 1000, // 1 hour }); // Mutations const createMutation = useMutation({ - mutationFn: ({ quote, items }: { quote: Partial; items: QuoteItem[] }) => - quoteService.createQuote(quote, items, user!.id, orgId), + mutationFn: ({ quote, items }: { quote: Partial; items: QuoteItem[] }) => { + if (!userId) throw new Error('Usuario nao autenticado'); + return quoteService.createQuote(quote, items, userId, orgId); + }, onSuccess: (newQuote) => { queryClient.invalidateQueries({ queryKey: ['quotes'] }); toast.success('Orçamento criado!', { description: `Número: ${newQuote.quote_number}` }); }, - onError: (err: any) => { - toast.error('Erro ao criar orçamento', { description: err.message }); - } + onError: (err: unknown) => { + toast.error('Erro ao criar orçamento', { description: getErrorMessage(err) }); + }, }); const updateMutation = useMutation({ - mutationFn: ({ quoteId, quote, items }: { quoteId: string; quote: Partial; items: QuoteItem[] }) => - quoteService.updateQuote(quoteId, quote, items), + mutationFn: ({ + quoteId, + quote, + items, + }: { + quoteId: string; + quote: Partial; + items: QuoteItem[]; + }) => quoteService.updateQuote(quoteId, quote, items), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['quotes'] }); toast.success('Orçamento atualizado!'); }, - onError: (err: any) => { - toast.error('Erro ao atualizar orçamento', { description: err.message }); - } + onError: (err: unknown) => { + toast.error('Erro ao atualizar orçamento', { description: getErrorMessage(err) }); + }, }); const statusMutation = useMutation({ - mutationFn: ({ quoteId, status }: { quoteId: string; status: Quote['status'] }) => + mutationFn: ({ quoteId, status }: { quoteId: string; status: Quote['status'] }) => quoteService.updateQuoteStatus(quoteId, status), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['quotes'] }); @@ -81,7 +105,7 @@ export function useQuotes() { }, onError: () => { toast.error('Erro ao atualizar status'); - } + }, }); const deleteMutation = useMutation({ @@ -92,15 +116,15 @@ export function useQuotes() { }, onError: () => { toast.error('Erro ao excluir orçamento'); - } + }, }); // Actions const fetchQuote = async (quoteId: string) => { try { return await quoteService.fetchQuote(quoteId); - } catch (err: any) { - toast.error('Erro ao carregar orçamento', { description: err.message }); + } catch (err: unknown) { + toast.error('Erro ao carregar orçamento', { description: getErrorMessage(err) }); return null; } }; @@ -139,30 +163,31 @@ export function useQuotes() { const original = await fetchQuote(quoteId); if (!original) throw new Error('Orçamento não encontrado'); - const items: QuoteItem[] = original.items?.map((item) => ({ - product_id: item.product_id, - product_name: item.product_name, - product_sku: item.product_sku, - product_image_url: item.product_image_url, - quantity: item.quantity, - unit_price: item.unit_price, - color_name: item.color_name, - color_hex: item.color_hex, - notes: item.notes, - personalizations: item.personalizations?.map((p) => ({ - technique_id: p.technique_id, - technique_name: p.technique_name, - colors_count: p.colors_count, - positions_count: p.positions_count, - area_cm2: p.area_cm2, - width_cm: p.width_cm, - height_cm: p.height_cm, - setup_cost: p.setup_cost, - unit_cost: p.unit_cost, - total_cost: p.total_cost, - notes: p.notes, - })), - })) || []; + const items: QuoteItem[] = + original.items?.map((item) => ({ + product_id: item.product_id, + product_name: item.product_name, + product_sku: item.product_sku, + product_image_url: item.product_image_url, + quantity: item.quantity, + unit_price: item.unit_price, + color_name: item.color_name, + color_hex: item.color_hex, + notes: item.notes, + personalizations: item.personalizations?.map((p) => ({ + technique_id: p.technique_id, + technique_name: p.technique_name, + colors_count: p.colors_count, + positions_count: p.positions_count, + area_cm2: p.area_cm2, + width_cm: p.width_cm, + height_cm: p.height_cm, + setup_cost: p.setup_cost, + unit_cost: p.unit_cost, + total_cost: p.total_cost, + notes: p.notes, + })), + })) || []; const newQuote = await createQuote( { @@ -186,8 +211,8 @@ export function useQuotes() { ); return newQuote; - } catch (err: any) { - toast.error('Erro ao duplicar', { description: err.message }); + } catch (err: unknown) { + toast.error('Erro ao duplicar', { description: getErrorMessage(err) }); return null; } }; @@ -200,14 +225,15 @@ export function useQuotes() { headers: log.headers(), }); if (fnError) throw new Error(fnError.message); - if (data.error) throw new Error(data.error); + const syncData = data as QuoteSyncResponse | null; + if (syncData?.error) throw new Error(syncData.error); toast.success('Sincronizado com Bitrix!', { - description: `Deal ID: ${data.bitrix_deal_id || 'N/A'}`, + description: `Deal ID: ${syncData?.bitrix_deal_id || 'N/A'}`, }); queryClient.invalidateQueries({ queryKey: ['quotes'] }); return true; - } catch (err: any) { - toast.error('Erro ao sincronizar', { description: err.message }); + } catch (err: unknown) { + toast.error('Erro ao sincronizar', { description: getErrorMessage(err) }); return false; } }; @@ -218,19 +244,25 @@ export function useQuotes() { body: { action: 'test_webhook', data: {} }, }); if (fnError) throw new Error(fnError.message); - if (data.success) { + const testData = data as QuoteSyncResponse | null; + if (testData?.success) { toast.success('Conexão com N8N estabelecida!'); return true; } toast.error('Falha na conexão com N8N'); return false; - } catch (err: any) { - toast.error('Erro ao testar webhook', { description: err.message }); + } catch (err: unknown) { + toast.error('Erro ao testar webhook', { description: getErrorMessage(err) }); return false; } }; - const logQuoteHistory = async (quoteId: string, action: string, description: string, options?: any) => { + const logQuoteHistory = async ( + quoteId: string, + action: string, + description: string, + options?: QuoteHistoryOptions, + ) => { if (!user) return; try { await quoteService.logHistory(quoteId, user.id, action, description, options); @@ -243,7 +275,7 @@ export function useQuotes() { quotes, techniques, isLoading: isLoading || createMutation.isPending || updateMutation.isPending, - error: error ? (error as any).message : null, + error: error ? getErrorMessage(error) : null, fetchQuotes, fetchQuote, createQuote, diff --git a/src/lib/external-db/price-tables.ts b/src/lib/external-db/price-tables.ts index 459645bfe..c28082827 100644 --- a/src/lib/external-db/price-tables.ts +++ b/src/lib/external-db/price-tables.ts @@ -37,12 +37,15 @@ export async function fetchPromobrindPriceTables(options?: { if (options?.techniqueCode) filters.table_code = options.techniqueCode; const result = await invokeExternalDb>({ - table: 'customization_price_tables', operation: 'select', - filters, select: '*', limit: 500, + table: 'customization_price_tables', + operation: 'select', + filters, + select: '*', + limit: 500, orderBy: { column: 'tier_1_min_qty', ascending: true }, }); - let tables: PromobrindPriceTable[] = result.records.map(r => ({ + let tables: PromobrindPriceTable[] = result.records.map((r) => ({ id: r.id as string, table_code: r.table_code as string, table_code_option: r.table_code_option as string, @@ -63,10 +66,21 @@ export async function fetchPromobrindPriceTables(options?: { technique_name: r.customization_type_name as string, })); - if (options?.quantity) tables = tables.filter(t => t.min_quantity <= options.quantity! && (t.max_quantity === null || t.max_quantity >= options.quantity!)); - if (options?.colors) tables = tables.filter(t => (t.min_colors === null || t.min_colors <= options.colors!) && (t.max_colors === null || t.max_colors >= options.colors!)); - if (options?.width) tables = tables.filter(t => t.max_area_width_cm === null || t.max_area_width_cm >= options.width!); - if (options?.height) tables = tables.filter(t => t.max_area_height_cm === null || t.max_area_height_cm >= options.height!); + const { quantity, colors, width, height } = options ?? {}; + if (quantity) + tables = tables.filter( + (t) => t.min_quantity <= quantity && (t.max_quantity === null || t.max_quantity >= quantity), + ); + if (colors) + tables = tables.filter( + (t) => + (t.min_colors === null || t.min_colors <= colors) && + (t.max_colors === null || t.max_colors >= colors), + ); + if (width) + tables = tables.filter((t) => t.max_area_width_cm === null || t.max_area_width_cm >= width); + if (height) + tables = tables.filter((t) => t.max_area_height_cm === null || t.max_area_height_cm >= height); return tables; } diff --git a/src/lib/external-db/products.ts b/src/lib/external-db/products.ts index b2704b3fe..f8370c5ad 100644 --- a/src/lib/external-db/products.ts +++ b/src/lib/external-db/products.ts @@ -18,17 +18,31 @@ import { // Row shapes for external_db_bridge results (untyped at runtime; assertions below). type VariantRow = { - id: string; product_id: string; sku?: string | null; - color_id?: string | null; color_name?: string | null; color_code?: string | null; - color_hex?: string | null; stock_quantity?: number | null; - selected_thumbnail?: string | null; images?: string[] | null; + id: string; + product_id: string; + sku?: string | null; + color_id?: string | null; + color_name?: string | null; + color_code?: string | null; + color_hex?: string | null; + stock_quantity?: number | null; + selected_thumbnail?: string | null; + images?: string[] | null; }; type ImageRow = { - product_id: string; variant_id: string | null; - url_cdn: string; url_original: string | null; filename: string | null; - image_type: string; is_primary: boolean; is_og_image: boolean | null; - applies_to_color: boolean | null; display_order: number; - supplier_code: string | null; alt_text: string | null; title_text: string | null; + product_id: string; + variant_id: string | null; + url_cdn: string; + url_original: string | null; + filename: string | null; + image_type: string; + is_primary: boolean; + is_og_image: boolean | null; + applies_to_color: boolean | null; + display_order: number; + supplier_code: string | null; + alt_text: string | null; + title_text: string | null; }; type SupplierRow = { id: string; name: string; code: string }; type ColorVariationRow = { id: string; name: string; slug: string; group_id: string }; @@ -78,17 +92,25 @@ export async function fetchPromobrindProducts(options?: { let result: InvokeResult; try { result = await invokeExternalDb({ - table: 'products', operation: 'select', filters, - select: PRODUCT_SELECT_FIELDS_WITH_SALE, orderBy, - limit: options.limit, offset: fetchOffset, + table: 'products', + operation: 'select', + filters, + select: PRODUCT_SELECT_FIELDS_WITH_SALE, + orderBy, + limit: options.limit, + offset: fetchOffset, countMode: shouldRequestCount ? 'planned' : 'none', }); } catch (err) { if (!shouldFallbackSelect(err)) throw err; result = await invokeExternalDb({ - table: 'products', operation: 'select', filters, - select: PRODUCT_SELECT_FIELDS_LEGACY, orderBy, - limit: options.limit, offset: fetchOffset, + table: 'products', + operation: 'select', + filters, + select: PRODUCT_SELECT_FIELDS_LEGACY, + orderBy, + limit: options.limit, + offset: fetchOffset, countMode: shouldRequestCount ? 'planned' : 'none', }); } @@ -107,7 +129,9 @@ export async function fetchPromobrindProducts(options?: { while (offset < HARD_MAX) { // Time-budget check: stop if we've been paginating too long if (Date.now() - PAGINATION_START > PAGINATION_TIMEOUT_MS) { - logger.warn(`[external-db] Pagination time budget exceeded (${PAGINATION_TIMEOUT_MS}ms). Got ${products.length} products at offset=${offset}.`); + logger.warn( + `[external-db] Pagination time budget exceeded (${PAGINATION_TIMEOUT_MS}ms). Got ${products.length} products at offset=${offset}.`, + ); break; } @@ -117,29 +141,47 @@ export async function fetchPromobrindProducts(options?: { let page: InvokeResult; try { page = await invokeExternalDb({ - table: 'products', operation: 'select', filters, - select: PRODUCT_SELECT_FIELDS_WITH_SALE, orderBy, - limit: pageSize, offset, countMode, + table: 'products', + operation: 'select', + filters, + select: PRODUCT_SELECT_FIELDS_WITH_SALE, + orderBy, + limit: pageSize, + offset, + countMode, }); consecutiveErrors = 0; } catch (err: unknown) { const msg = err instanceof Error ? err.message : ''; - if (msg.includes('statement timeout') || msg.includes('57014') || msg.includes('canceling statement')) { + if ( + msg.includes('statement timeout') || + msg.includes('57014') || + msg.includes('canceling statement') + ) { consecutiveErrors++; if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - logger.warn(`[external-db] Stopping pagination at offset=${offset} after ${MAX_CONSECUTIVE_ERRORS} consecutive timeouts. Got ${products.length} products so far.`); + logger.warn( + `[external-db] Stopping pagination at offset=${offset} after ${MAX_CONSECUTIVE_ERRORS} consecutive timeouts. Got ${products.length} products so far.`, + ); break; } - logger.warn(`[external-db] Timeout at offset=${offset}, retrying (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS})...`); - await new Promise(r => setTimeout(r, 1000 * consecutiveErrors)); + logger.warn( + `[external-db] Timeout at offset=${offset}, retrying (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS})...`, + ); + await new Promise((r) => setTimeout(r, 1000 * consecutiveErrors)); continue; } if (!shouldFallbackSelect(err)) throw err; try { page = await invokeExternalDb({ - table: 'products', operation: 'select', filters, - select: PRODUCT_SELECT_FIELDS_LEGACY, orderBy, - limit: pageSize, offset, countMode, + table: 'products', + operation: 'select', + filters, + select: PRODUCT_SELECT_FIELDS_LEGACY, + orderBy, + limit: pageSize, + offset, + countMode, }); consecutiveErrors = 0; } catch (fallbackErr: unknown) { @@ -147,20 +189,23 @@ export async function fetchPromobrindProducts(options?: { if (fbMsg.includes('statement timeout') || fbMsg.includes('canceling statement')) { consecutiveErrors++; if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - logger.warn(`[external-db] Stopping pagination (fallback) at offset=${offset}. Got ${products.length} products.`); + logger.warn( + `[external-db] Stopping pagination (fallback) at offset=${offset}. Got ${products.length} products.`, + ); break; } - await new Promise(r => setTimeout(r, 1000 * consecutiveErrors)); + await new Promise((r) => setTimeout(r, 1000 * consecutiveErrors)); continue; } throw fallbackErr; } } - if (typeof page!.count === 'number') loopCount = page!.count; - products.push(...page!.records); - offset += page!.records.length; - if (page!.records.length < pageSize) break; + if (!page) break; + if (typeof page.count === 'number') loopCount = page.count; + products.push(...page.records); + offset += page.records.length; + if (page.records.length < pageSize) break; if (loopCount !== null && products.length >= loopCount) break; } totalCount = loopCount; @@ -181,16 +226,17 @@ export async function fetchPromobrindProducts(options?: { // ENRICHMENT LOGIC // ============================================ -async function enrichProducts( - products: PromobrindProduct[], - options?: { limit?: number } -) { - const productIds = products.map(p => p.id); - const uniqueSupplierIds = [...new Set(products.map(p => p.supplier_id).filter(Boolean))] as string[]; +async function enrichProducts(products: PromobrindProduct[], options?: { limit?: number }) { + const productIds = products.map((p) => p.id); + const uniqueSupplierIds = [ + ...new Set(products.map((p) => p.supplier_id).filter(Boolean)), + ] as string[]; const shouldRunHeavyEnrichment = products.length <= 500 || typeof options?.limit === 'number'; if (!shouldRunHeavyEnrichment) { - logger.info(`[external-db] Skipping heavy enrichment for ${products.length} products to prevent timeouts`); + logger.info( + `[external-db] Skipping heavy enrichment for ${products.length} products to prevent timeouts`, + ); } const CHUNK_SIZE = 80; @@ -200,37 +246,53 @@ async function enrichProducts( } const batchQueries: BatchQuery[] = []; - const queryMap: Record = { variants: [], images: [], suppliers: [], colorVariations: [], colorGroups: [] }; + const queryMap: Record = { + variants: [], + images: [], + suppliers: [], + colorVariations: [], + colorGroups: [], + }; if (shouldRunHeavyEnrichment) { for (const chunk of idChunks) { queryMap.variants.push(batchQueries.length); batchQueries.push({ table: 'product_variants', - select: 'product_id, color_name, color_hex, color_code, color_id, sku, stock_quantity, images, selected_thumbnail', + select: + 'product_id, color_name, color_hex, color_code, color_id, sku, stock_quantity, images, selected_thumbnail', filters: { is_active: true, product_id: chunk }, - limit: 1000, offset: 0, + limit: 1000, + offset: 0, }); } for (const chunk of idChunks) { queryMap.images.push(batchQueries.length); batchQueries.push({ table: 'product_images', - select: 'product_id, url_cdn, url_original, filename, image_type, is_primary, is_og_image, applies_to_color, display_order, alt_text, title_text, supplier_code, variant_id', + select: + 'product_id, url_cdn, url_original, filename, image_type, is_primary, is_og_image, applies_to_color, display_order, alt_text, title_text, supplier_code, variant_id', filters: { is_active: true, product_id: chunk }, - limit: 1000, offset: 0, + limit: 1000, + offset: 0, }); } queryMap.colorVariations.push(batchQueries.length); batchQueries.push({ - table: 'color_variations', select: 'id, name, slug, group_id', - filters: { is_active: true }, limit: 500, offset: 0, + table: 'color_variations', + select: 'id, name, slug, group_id', + filters: { is_active: true }, + limit: 500, + offset: 0, cacheKey: 'ref:color_variations', }); queryMap.colorGroups.push(batchQueries.length); batchQueries.push({ - table: 'color_groups', select: 'id, name, slug', - filters: { is_active: true }, limit: 100, offset: 0, + table: 'color_groups', + select: 'id, name, slug', + filters: { is_active: true }, + limit: 100, + offset: 0, cacheKey: 'ref:color_groups', }); } @@ -238,9 +300,11 @@ async function enrichProducts( if (uniqueSupplierIds.length > 0) { queryMap.suppliers.push(batchQueries.length); batchQueries.push({ - table: 'suppliers', select: 'id, name, code', + table: 'suppliers', + select: 'id, name, code', filters: { id: uniqueSupplierIds }, - limit: Math.max(uniqueSupplierIds.length, 1), offset: 0, + limit: Math.max(uniqueSupplierIds.length, 1), + offset: 0, }); } @@ -272,7 +336,8 @@ async function enrichProducts( let colorVariationsRecords: ColorVariationRow[] = []; for (const idx of queryMap.colorVariations) { const r = batchResults[idx]; - if (r?.success && r.data?.records) colorVariationsRecords = r.data.records as ColorVariationRow[]; + if (r?.success && r.data?.records) + colorVariationsRecords = r.data.records as ColorVariationRow[]; } let colorGroupsRecords: ColorGroupRow[] = []; for (const idx of queryMap.colorGroups) { @@ -280,95 +345,165 @@ async function enrichProducts( if (r?.success && r.data?.records) colorGroupsRecords = r.data.records as ColorGroupRow[]; } - const suppliersMap = new Map(suppliersRecords.map(s => [s.id, s.name])); + const suppliersMap = new Map(suppliersRecords.map((s) => [s.id, s.name])); // Popula cache de imutáveis para reaproveitar em telas de detalhe sem ida ao bridge. try { const { putInCacheSafe } = await import('./immutableCache'); for (const s of suppliersRecords) { if (s?.id && s?.name) putInCacheSafe('suppliers', { id: s.id, name: s.name, code: s.code }); } - } catch { /* cache populate is best-effort */ } - const colorVariationMap = new Map(colorVariationsRecords.map((v) => [v.id, { name: v.name, slug: v.slug, group_id: v.group_id }])); - const colorGroupMap = new Map(colorGroupsRecords.map((g) => [g.id, { name: g.name, slug: g.slug }])); + } catch { + /* cache populate is best-effort */ + } + const colorVariationMap = new Map( + colorVariationsRecords.map((v) => [v.id, { name: v.name, slug: v.slug, group_id: v.group_id }]), + ); + const colorGroupMap = new Map( + colorGroupsRecords.map((g) => [g.id, { name: g.name, slug: g.slug }]), + ); // Build image map const productIdSet = new Set(productIds); - const imagesByProduct = new Map>(); + const imagesByProduct = new Map< + string, + Array<{ + url: string; + urlOriginal: string | null; + filename: string | null; + type: string; + isPrimary: boolean; + isOgImage: boolean; + appliesToColor: boolean | null; + order: number; + supplierCode: string | null; + altText: string | null; + titleText: string | null; + variantId: string | null; + }> + >(); imagesRecords.forEach((img) => { if (!productIdSet.has(img.product_id)) return; - if (!imagesByProduct.has(img.product_id)) imagesByProduct.set(img.product_id, []); - imagesByProduct.get(img.product_id)!.push({ - url: img.url_cdn, urlOriginal: img.url_original || null, - filename: img.filename || null, type: img.image_type, - isPrimary: img.is_primary, isOgImage: img.is_og_image || false, - appliesToColor: img.applies_to_color ?? null, order: img.display_order, - supplierCode: img.supplier_code || null, altText: img.alt_text || null, - titleText: img.title_text || null, variantId: img.variant_id || null, + const productImages = imagesByProduct.get(img.product_id) ?? []; + imagesByProduct.set(img.product_id, productImages); + productImages.push({ + url: img.url_cdn, + urlOriginal: img.url_original || null, + filename: img.filename || null, + type: img.image_type, + isPrimary: img.is_primary, + isOgImage: img.is_og_image || false, + appliesToColor: img.applies_to_color ?? null, + order: img.display_order, + supplierCode: img.supplier_code || null, + altText: img.alt_text || null, + titleText: img.title_text || null, + variantId: img.variant_id || null, }); }); // Build color map - const colorsByProduct = new Map>(); + const colorsByProduct = new Map< + string, + Array<{ + name: string; + hex: string; + code: string; + sku?: string; + stock?: number; + image?: string; + images?: string[]; + groupSlug?: string; + groupName?: string; + variationSlug?: string; + }> + >(); variantsRecords.forEach((variant) => { if (!variant.color_name || !productIds.includes(variant.product_id)) return; - if (!colorsByProduct.has(variant.product_id)) colorsByProduct.set(variant.product_id, []); - const colors = colorsByProduct.get(variant.product_id)!; - if (colors.some(c => c.name === variant.color_name)) return; + const colors = colorsByProduct.get(variant.product_id) ?? []; + colorsByProduct.set(variant.product_id, colors); + if (colors.some((c) => c.name === variant.color_name)) return; const productImgs = imagesByProduct.get(variant.product_id) || []; - const byVariantId = productImgs.filter(img => img.variantId === variant.id && !img.isPrimary && !img.isOgImage).sort((a, b) => a.order - b.order).map(img => img.url); + const byVariantId = productImgs + .filter((img) => img.variantId === variant.id && !img.isPrimary && !img.isOgImage) + .sort((a, b) => a.order - b.order) + .map((img) => img.url); const byCode = variant.color_code - ? productImgs.filter(img => img.supplierCode === variant.color_code && !img.isPrimary && !img.isOgImage).sort((a, b) => a.order - b.order).map(img => img.url) - : []; - const allById = byVariantId.length === 0 - ? productImgs.filter(img => img.variantId === variant.id).sort((a, b) => a.order - b.order).map(img => img.url) + ? productImgs + .filter( + (img) => img.supplierCode === variant.color_code && !img.isPrimary && !img.isOgImage, + ) + .sort((a, b) => a.order - b.order) + .map((img) => img.url) : []; + const allById = + byVariantId.length === 0 + ? productImgs + .filter((img) => img.variantId === variant.id) + .sort((a, b) => a.order - b.order) + .map((img) => img.url) + : []; const legacy = variant.images?.length ? variant.images : []; - const finalImages = byVariantId.length > 0 ? byVariantId : byCode.length > 0 ? byCode : allById.length > 0 ? allById : legacy; + const finalImages = + byVariantId.length > 0 + ? byVariantId + : byCode.length > 0 + ? byCode + : allById.length > 0 + ? allById + : legacy; const thumbnailImage = finalImages[0] || variant.selected_thumbnail || null; - let groupSlug: string | undefined, groupName: string | undefined, variationSlug: string | undefined; + let groupSlug: string | undefined, + groupName: string | undefined, + variationSlug: string | undefined; if (variant.color_id) { const variation = colorVariationMap.get(variant.color_id); if (variation) { variationSlug = variation.slug; const group = colorGroupMap.get(variation.group_id); - if (group) { groupSlug = group.slug; groupName = group.name; } + if (group) { + groupSlug = group.slug; + groupName = group.name; + } } } colors.push({ - name: variant.color_name, hex: variant.color_hex || '#CCCCCC', - code: variant.color_code || '', sku: variant.sku || undefined, - stock: variant.stock_quantity ?? undefined, image: thumbnailImage || undefined, + name: variant.color_name, + hex: variant.color_hex || '#CCCCCC', + code: variant.color_code || '', + sku: variant.sku || undefined, + stock: variant.stock_quantity ?? undefined, + image: thumbnailImage || undefined, images: finalImages.length > 0 ? finalImages : undefined, - groupSlug, groupName, variationSlug, + groupSlug, + groupName, + variationSlug, }); }); // Apply enrichments to products - products.forEach(product => { + products.forEach((product) => { const productImages = imagesByProduct.get(product.id); if (productImages && productImages.length > 0) { productImages.sort((a, b) => a.order - b.order); - const colorImages = productImages.filter(img => img.supplierCode && img.type !== 'box'); - const generalImages = productImages.filter(img => !img.supplierCode && img.type !== 'box'); + const colorImages = productImages.filter((img) => img.supplierCode && img.type !== 'box'); + const generalImages = productImages.filter((img) => !img.supplierCode && img.type !== 'box'); const mainImages = [...colorImages, ...generalImages]; - const primaryImage = mainImages.find(img => img.isPrimary) || mainImages[0]; - if (primaryImage) { product.primary_image_url = primaryImage.url; product.image_url = primaryImage.url; } - const ogImage = mainImages.find(img => img.isOgImage) || mainImages.find(img => img.type === 'main') || primaryImage; + const primaryImage = mainImages.find((img) => img.isPrimary) || mainImages[0]; + if (primaryImage) { + product.primary_image_url = primaryImage.url; + product.image_url = primaryImage.url; + } + const ogImage = + mainImages.find((img) => img.isOgImage) || + mainImages.find((img) => img.type === 'main') || + primaryImage; if (ogImage) product.og_image_url = ogImage.url; - product.images = mainImages.map(img => img.url); + product.images = mainImages.map((img) => img.url); } const variantColors = colorsByProduct.get(product.id); if (variantColors?.length) product.colors = variantColors; diff --git a/src/lib/personalization/selectors.ts b/src/lib/personalization/selectors.ts index 407fa1982..0a821aacb 100644 --- a/src/lib/personalization/selectors.ts +++ b/src/lib/personalization/selectors.ts @@ -1,6 +1,6 @@ /** * Domain Selectors: Personalização - * + * * Funções puras para seleção e filtragem de dados. */ @@ -11,7 +11,7 @@ import type { ColorOption, SizeOption, PriceTier, -} from "./types"; +} from './types'; // ============================================ // TABLE SELECTION @@ -23,60 +23,62 @@ import type { */ export function selectBestTable( tables: PriceTableInput[], - criteria: TableSelectionCriteria + criteria: TableSelectionCriteria, ): PriceTableInput | null { if (tables.length === 0) return null; - + // Filtrar apenas tabelas ativas - let candidates = tables.filter(t => t.isActive); - + let candidates = tables.filter((t) => t.isActive); + if (candidates.length === 0) return null; - + // Filtrar por nome da técnica if (criteria.techniqueName) { - const byName = candidates.filter(t => - t.techniqueName.toLowerCase().includes(criteria.techniqueName!.toLowerCase()) + const byName = candidates.filter((t) => + t.techniqueName.toLowerCase().includes(criteria.techniqueName.toLowerCase()), ); if (byName.length > 0) candidates = byName; } - + // Filtrar por código da técnica if (criteria.techniqueCode) { - const byCode = candidates.filter(t => - t.tableCode.toLowerCase().includes(criteria.techniqueCode!.toLowerCase()) || - criteria.techniqueCode!.toLowerCase().includes(t.tableCode.toLowerCase()) + const byCode = candidates.filter( + (t) => + t.tableCode.toLowerCase().includes(criteria.techniqueCode.toLowerCase()) || + criteria.techniqueCode.toLowerCase().includes(t.tableCode.toLowerCase()), ); if (byCode.length > 0) candidates = byCode; } - + // Ordenar por número de cores (preferir a que atende exatamente) if (criteria.colors) { candidates.sort((a, b) => { - const aFits = a.maxColors !== null && a.maxColors >= criteria.colors!; - const bFits = b.maxColors !== null && b.maxColors >= criteria.colors!; - + const aFits = a.maxColors !== null && a.maxColors >= criteria.colors; + const bFits = b.maxColors !== null && b.maxColors >= criteria.colors; + if (aFits && !bFits) return -1; if (!aFits && bFits) return 1; - + // Preferir a menor que ainda atende if (aFits && bFits) { return (a.maxColors || 0) - (b.maxColors || 0); } - + // Se nenhuma atende, preferir a maior return (b.maxColors || 0) - (a.maxColors || 0); }); } - + // Filtrar por dimensões if (criteria.widthCm && criteria.heightCm) { - const byDimensions = candidates.filter(t => - (t.maxWidthCm === null || t.maxWidthCm >= criteria.widthCm!) && - (t.maxHeightCm === null || t.maxHeightCm >= criteria.heightCm!) + const byDimensions = candidates.filter( + (t) => + (t.maxWidthCm === null || t.maxWidthCm >= criteria.widthCm) && + (t.maxHeightCm === null || t.maxHeightCm >= criteria.heightCm), ); if (byDimensions.length > 0) candidates = byDimensions; } - + return candidates[0]; } @@ -85,32 +87,31 @@ export function selectBestTable( */ export function filterTablesByTechnique( tables: PriceTableInput[], - techniqueName: string + techniqueName: string, ): PriceTableInput[] { const normalized = techniqueName.toLowerCase(); - - return tables.filter(t => - t.techniqueName.toLowerCase().includes(normalized) || - t.tableCode.toLowerCase().includes(normalized) + + return tables.filter( + (t) => + t.techniqueName.toLowerCase().includes(normalized) || + t.tableCode.toLowerCase().includes(normalized), ); } /** * Agrupa tabelas por nome de técnica */ -export function groupTablesByTechnique( - tables: PriceTableInput[] -): Map { +export function groupTablesByTechnique(tables: PriceTableInput[]): Map { const grouped = new Map(); - + for (const table of tables) { const key = table.techniqueName; if (!grouped.has(key)) { grouped.set(key, []); } - grouped.get(key)!.push(table); + grouped.get(key)?.push(table); } - + return grouped; } @@ -123,21 +124,19 @@ export function groupTablesByTechnique( */ export function filterTechniquesByCategory( techniques: TechniqueInput[], - category: string + category: string, ): TechniqueInput[] { - return techniques.filter(t => - t.category.toLowerCase() === category.toLowerCase() && t.isActive + return techniques.filter( + (t) => t.category.toLowerCase() === category.toLowerCase() && t.isActive, ); } /** * Retorna técnicas únicas (por código) */ -export function getUniqueTechniques( - techniques: TechniqueInput[] -): TechniqueInput[] { +export function getUniqueTechniques(techniques: TechniqueInput[]): TechniqueInput[] { const seen = new Set(); - return techniques.filter(t => { + return techniques.filter((t) => { if (seen.has(t.code)) return false; seen.add(t.code); return true; @@ -148,7 +147,7 @@ export function getUniqueTechniques( * Retorna categorias únicas das técnicas */ export function getUniqueCategories(techniques: TechniqueInput[]): string[] { - const categories = [...new Set(techniques.map(t => t.category))]; + const categories = [...new Set(techniques.map((t) => t.category))]; return categories.sort(); } @@ -161,19 +160,15 @@ export function getUniqueCategories(techniques: TechniqueInput[]): string[] { */ export function extractColorOptions( tables: PriceTableInput[], - hasPriceByColor: boolean + hasPriceByColor: boolean, ): ColorOption[] { if (!hasPriceByColor || tables.length === 0) return []; - + // Coletar todos os maxColors únicos const uniqueColors = [ - ...new Set( - tables - .map(t => t.maxColors) - .filter((c): c is number => c !== null && c > 0) - ), + ...new Set(tables.map((t) => t.maxColors).filter((c): c is number => c !== null && c > 0)), ].sort((a, b) => a - b); - + // Se só há um valor, criar opções de 1 até o máximo if (uniqueColors.length <= 1) { const maxColors = uniqueColors[0] || 4; @@ -182,9 +177,9 @@ export function extractColorOptions( label: `${i + 1} ${i === 0 ? 'cor' : 'cores'}`, })); } - + // Se há variação, usar os valores disponíveis - return uniqueColors.map(c => ({ + return uniqueColors.map((c) => ({ value: c, label: `${c} ${c === 1 ? 'cor' : 'cores'}`, })); @@ -195,13 +190,13 @@ export function extractColorOptions( */ export function extractSizeOptions(tables: PriceTableInput[]): SizeOption[] { if (tables.length === 0) return []; - + const uniqueAreas = new Map(); - + for (const table of tables) { const width = table.maxWidthCm; const height = table.maxHeightCm; - + if (width && height && width > 0 && height > 0) { const key = `${width}x${height}`; if (!uniqueAreas.has(key)) { @@ -216,7 +211,7 @@ export function extractSizeOptions(tables: PriceTableInput[]): SizeOption[] { } } } - + // Ordenar por área return Array.from(uniqueAreas.values()).sort((a, b) => a.areaCm2 - b.areaCm2); } @@ -226,8 +221,8 @@ export function extractSizeOptions(tables: PriceTableInput[]): SizeOption[] { */ export function extractQuantityOptions(tiers: PriceTier[]): number[] { if (tiers.length === 0) return [1, 10, 50, 100, 500]; - - return tiers.map(t => t.minQuantity).sort((a, b) => a - b); + + return tiers.map((t) => t.minQuantity).sort((a, b) => a - b); } // ============================================ @@ -240,29 +235,27 @@ export function extractQuantityOptions(tiers: PriceTier[]): number[] { */ export function calculateTableScore( table: PriceTableInput, - criteria: TableSelectionCriteria + criteria: TableSelectionCriteria, ): number { let score = 0; - + // Base: tabela ativa if (table.isActive) score += 100; - + // Match por nome if (criteria.techniqueName) { - const nameMatch = table.techniqueName.toLowerCase().includes( - criteria.techniqueName.toLowerCase() - ); + const nameMatch = table.techniqueName + .toLowerCase() + .includes(criteria.techniqueName.toLowerCase()); if (nameMatch) score += 50; } - + // Match por código if (criteria.techniqueCode) { - const codeMatch = table.tableCode.toLowerCase().includes( - criteria.techniqueCode.toLowerCase() - ); + const codeMatch = table.tableCode.toLowerCase().includes(criteria.techniqueCode.toLowerCase()); if (codeMatch) score += 50; } - + // Match por cores if (criteria.colors && table.maxColors !== null) { if (table.maxColors >= criteria.colors) { @@ -272,16 +265,16 @@ export function calculateTableScore( score -= 20; // Penaliza se não atende } } - + // Match por dimensões if (criteria.widthCm && criteria.heightCm) { const fitsWidth = table.maxWidthCm === null || table.maxWidthCm >= criteria.widthCm; const fitsHeight = table.maxHeightCm === null || table.maxHeightCm >= criteria.heightCm; - + if (fitsWidth && fitsHeight) score += 20; else score -= 10; } - + return score; } @@ -290,7 +283,7 @@ export function calculateTableScore( */ export function rankTablesByCriteria( tables: PriceTableInput[], - criteria: TableSelectionCriteria + criteria: TableSelectionCriteria, ): PriceTableInput[] { return [...tables].sort((a, b) => { const scoreA = calculateTableScore(a, criteria);