Compare commits
5 Commits
090fa21ba5
...
634cf4db48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634cf4db48 | ||
|
|
423442578f | ||
|
|
34a75e64f6 | ||
|
|
0b883dd8c2 | ||
|
|
7668b83de6 |
@@ -23,7 +23,7 @@ const BackButton = ({ fallbackTo, label = 'Voltar' }: BackButtonProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="inline-flex w-fit items-center text-sm font-bold text-zinc-400 dark:text-dark-muted transition-colors hover:text-zinc-900 dark:hover:text-dark-text"
|
className="inline-flex w-fit cursor-pointer items-center text-sm font-bold text-zinc-400 dark:text-dark-muted transition-colors hover:text-zinc-900 dark:hover:text-dark-text"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
63
src/components/ProductColorBadge.tsx
Normal file
63
src/components/ProductColorBadge.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
|
||||||
|
{ pattern: 'preto', color: '#171717' },
|
||||||
|
{ pattern: 'branco', color: '#f8fafc' },
|
||||||
|
{ pattern: 'bege', color: '#d7bf9a' },
|
||||||
|
{ pattern: 'cafe', color: '#79553d' },
|
||||||
|
{ pattern: 'perola', color: '#e7dfcf' },
|
||||||
|
{ pattern: 'marinho', color: '#172554' },
|
||||||
|
{ pattern: 'bordo', color: '#6b1226' },
|
||||||
|
{ pattern: 'verde', color: '#166534' },
|
||||||
|
{ pattern: 'rosa', color: '#f0a6bf' },
|
||||||
|
{ pattern: 'cinza', color: '#8f8f8f' },
|
||||||
|
{ pattern: 'vermelho', color: '#b91c1c' },
|
||||||
|
{ pattern: 'grafite', color: '#3f3f46' },
|
||||||
|
{ pattern: 'azul', color: '#2563eb' },
|
||||||
|
{ pattern: 'marron', color: '#6b4f3b' },
|
||||||
|
{ pattern: 'marrom', color: '#6b4f3b' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizeColorLabel = (label: string) => (
|
||||||
|
label.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getProductColor = (label: string) => {
|
||||||
|
const normalizedLabel = normalizeColorLabel(label);
|
||||||
|
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern))?.color || '#64748b';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductColorSwatch = ({
|
||||||
|
label,
|
||||||
|
className = 'h-2.5 w-2.5'
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<span
|
||||||
|
className={`${className} shrink-0 rounded-full`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: getProductColor(label),
|
||||||
|
boxShadow: '0 0 0 1px var(--color-dark-card), 0 0 0 2px var(--color-dark-border)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProductColorBadge = ({
|
||||||
|
label,
|
||||||
|
emptyLabel = 'Sem cor',
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const displayLabel = label?.trim() || emptyLabel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex max-w-full items-center gap-2 rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text ${className}`}>
|
||||||
|
<ProductColorSwatch label={displayLabel} className="h-2 w-2" />
|
||||||
|
<span className="truncate">{displayLabel}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductColorBadge;
|
||||||
@@ -3,6 +3,7 @@ import { Link, useOutletContext } from 'react-router-dom';
|
|||||||
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
|
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
|
import ProductColorBadge from '../components/ProductColorBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||||
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
||||||
@@ -13,6 +14,7 @@ type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_de
|
|||||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||||
type CorrectionIssueFilter = CutProductIssue | 'all';
|
type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||||
|
type SettingsSection = 'rules' | 'corrections';
|
||||||
|
|
||||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||||
const coverageTargetOptions = [7, 15, 30, 60];
|
const coverageTargetOptions = [7, 15, 30, 60];
|
||||||
@@ -25,7 +27,7 @@ const familyOptions: Array<{ value: CutFamilyKey | 'all'; label: string }> = [
|
|||||||
const filterOptions: Array<{ value: CutFilter; label: string }> = [
|
const filterOptions: Array<{ value: CutFilter; label: string }> = [
|
||||||
{ value: 'need', label: 'Com necessidade' },
|
{ value: 'need', label: 'Com necessidade' },
|
||||||
{ value: 'all', label: 'Todos' },
|
{ value: 'all', label: 'Todos' },
|
||||||
{ value: 'issues', label: 'Pendências' },
|
{ value: 'issues', label: 'Dados pendentes' },
|
||||||
{ value: 'covered', label: 'Sem necessidade' }
|
{ value: 'covered', label: 'Sem necessidade' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ const issueHelp: Record<CutIssue, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [
|
const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [
|
||||||
{ value: 'all', label: 'Todas pendências' },
|
{ value: 'all', label: 'Todos os dados pendentes' },
|
||||||
{ value: 'missing_family_rule', label: issueLabels.missing_family_rule },
|
{ value: 'missing_family_rule', label: issueLabels.missing_family_rule },
|
||||||
{ value: 'missing_color', label: issueLabels.missing_color },
|
{ value: 'missing_color', label: issueLabels.missing_color },
|
||||||
{ value: 'missing_size', label: issueLabels.missing_size }
|
{ value: 'missing_size', label: issueLabels.missing_size }
|
||||||
@@ -123,6 +125,7 @@ const Cutting = () => {
|
|||||||
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
|
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
|
||||||
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
||||||
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||||
@@ -260,10 +263,6 @@ const Cutting = () => {
|
|||||||
const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length;
|
const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length;
|
||||||
const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length;
|
const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCorrectionPage(1);
|
|
||||||
}, [correctionIssueFilter, dateRange, targetCoverageDays]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSettingsOpen) return undefined;
|
if (!isSettingsOpen) return undefined;
|
||||||
|
|
||||||
@@ -349,6 +348,11 @@ const Cutting = () => {
|
|||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSettingsSection = (section: SettingsSection) => {
|
||||||
|
setSettingsSection(section);
|
||||||
|
setIsSettingsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const exportRows = () => {
|
const exportRows = () => {
|
||||||
exportToCSV(filteredRows.map(row => ({
|
exportToCSV(filteredRows.map(row => ({
|
||||||
'ID Produto': row.id,
|
'ID Produto': row.id,
|
||||||
@@ -367,7 +371,7 @@ const Cutting = () => {
|
|||||||
'Rendimento un/rolo': row.family.unitsPerRoll || '',
|
'Rendimento un/rolo': row.family.unitsPerRoll || '',
|
||||||
'Rolos estimados': row.estimatedRolls || '',
|
'Rolos estimados': row.estimatedRolls || '',
|
||||||
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
||||||
'Pendências': row.issues.map(issue => issueLabels[issue]).join(' | ')
|
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
|
||||||
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -385,7 +389,7 @@ const Cutting = () => {
|
|||||||
>
|
>
|
||||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} pendências`}
|
{row.issues.length === 1 ? issueLabels[row.issues[0]] : `${row.issues.length} dados`}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -407,6 +411,7 @@ const Cutting = () => {
|
|||||||
onChange={(range) => {
|
onChange={(range) => {
|
||||||
setDateRange(range);
|
setDateRange(range);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setCorrectionPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -422,9 +427,9 @@ const Cutting = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSettingsOpen(current => !current)}
|
onClick={() => openSettingsSection('rules')}
|
||||||
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
|
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
|
||||||
title="Configurar regras de corte"
|
title="Configurar rendimentos por família"
|
||||||
>
|
>
|
||||||
<Settings2 size={16} className="text-brand-primary" />
|
<Settings2 size={16} className="text-brand-primary" />
|
||||||
<span className="hidden sm:inline">Regras</span>
|
<span className="hidden sm:inline">Regras</span>
|
||||||
@@ -448,7 +453,7 @@ const Cutting = () => {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold text-dark-text">Regras de Corte</h2>
|
<h2 className="text-sm font-bold text-dark-text">Configuração de Corte</h2>
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
{configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto
|
{configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto
|
||||||
</p>
|
</p>
|
||||||
@@ -460,7 +465,7 @@ const Cutting = () => {
|
|||||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
Limpar regras
|
Limpar tudo
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -469,7 +474,7 @@ const Cutting = () => {
|
|||||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-3 py-2 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-3 py-2 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<SaveIcon className="h-4 w-4" />
|
<SaveIcon className="h-4 w-4" />
|
||||||
{saveStatus === 'saving' ? 'Salvando' : 'Salvar regras'}
|
{saveStatus === 'saving' ? 'Salvando' : 'Salvar'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -492,6 +497,26 @@ const Cutting = () => {
|
|||||||
<p className="text-xs font-semibold text-amber-300">Existem alterações ainda não salvas.</p>
|
<p className="text-xs font-semibold text-amber-300">Existem alterações ainda não salvas.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsSection('rules')}
|
||||||
|
aria-pressed={settingsSection === 'rules'}
|
||||||
|
className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${settingsSection === 'rules' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:text-dark-text'}`}
|
||||||
|
>
|
||||||
|
Rendimentos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsSection('corrections')}
|
||||||
|
aria-pressed={settingsSection === 'corrections'}
|
||||||
|
className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${settingsSection === 'corrections' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted hover:text-dark-text'}`}
|
||||||
|
>
|
||||||
|
Correções
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settingsSection === 'rules' && (
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||||
{CUT_FAMILY_RULES.map(rule => (
|
{CUT_FAMILY_RULES.map(rule => (
|
||||||
<label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3">
|
<label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3">
|
||||||
@@ -511,7 +536,9 @@ const Cutting = () => {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settingsSection === 'corrections' && (
|
||||||
<div className="rounded-xl border border-dark-border">
|
<div className="rounded-xl border border-dark-border">
|
||||||
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-header p-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-header p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -626,10 +653,11 @@ const Cutting = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-8 text-center">
|
<div className="px-4 py-8 text-center">
|
||||||
<p className="text-sm font-bold text-dark-text">Nenhuma correção de produto pendente.</p>
|
<p className="text-sm font-bold text-dark-text">Nenhuma correção de produto pendente.</p>
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">As pendências restantes, se existirem, são de rendimento por família.</p>
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Os dados restantes, se existirem, são de rendimento por família.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -696,8 +724,8 @@ const Cutting = () => {
|
|||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||||
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold text-dark-text">Pendências do plano</h2>
|
<h2 className="text-sm font-bold text-dark-text">Dados pendentes do plano</h2>
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Itens que ainda precisam de regra, cor, tamanho ou rendimento.</p>
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Dados que bloqueiam o cálculo completo: família, cor, tamanho ou rendimento.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-bold text-amber-300">
|
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-bold text-amber-300">
|
||||||
@@ -705,7 +733,11 @@ const Cutting = () => {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => {
|
||||||
|
setCorrectionIssueFilter('all');
|
||||||
|
setCorrectionPage(1);
|
||||||
|
openSettingsSection('corrections');
|
||||||
|
}}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4 text-brand-primary" />
|
<Settings2 className="h-4 w-4 text-brand-primary" />
|
||||||
@@ -715,26 +747,16 @@ const Cutting = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||||
{issueSummaries.map(summary => (
|
{issueSummaries.map(summary => (
|
||||||
<button
|
<div
|
||||||
key={summary.issue}
|
key={summary.issue}
|
||||||
type="button"
|
className="rounded-xl border border-dark-border bg-dark-input/60 p-3"
|
||||||
onClick={() => {
|
|
||||||
setCutFilter('issues');
|
|
||||||
setIsSettingsOpen(true);
|
|
||||||
if (summary.issue !== 'missing_yield_rule') {
|
|
||||||
setCorrectionIssueFilter(summary.issue);
|
|
||||||
}
|
|
||||||
setCurrentPage(1);
|
|
||||||
setCorrectionPage(1);
|
|
||||||
}}
|
|
||||||
className="rounded-xl border border-dark-border bg-dark-input/60 p-3 text-left transition-colors hover:border-amber-400/50 cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span>
|
<span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span>
|
||||||
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span>
|
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p>
|
<p className="mt-1 line-clamp-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -751,15 +773,9 @@ const Cutting = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
|
||||||
{cutPlan.familySummaries.map(summary => (
|
{cutPlan.familySummaries.map(summary => (
|
||||||
<button
|
<div
|
||||||
key={summary.family.key}
|
key={summary.family.key}
|
||||||
type="button"
|
className="rounded-xl border border-dark-border bg-dark-input/50 p-3"
|
||||||
onClick={() => {
|
|
||||||
setFamilyFilter(summary.family.key);
|
|
||||||
setCutFilter('need');
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="rounded-xl border border-dark-border bg-dark-input/50 p-3 text-left transition-colors hover:border-brand-primary cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -774,7 +790,7 @@ const Cutting = () => {
|
|||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
{formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}
|
{formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -800,6 +816,7 @@ const Cutting = () => {
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setTargetCoverageDays(Number(event.target.value));
|
setTargetCoverageDays(Number(event.target.value));
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setCorrectionPage(1);
|
||||||
}}
|
}}
|
||||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
||||||
aria-label="Dias de cobertura alvo"
|
aria-label="Dias de cobertura alvo"
|
||||||
@@ -874,7 +891,12 @@ const Cutting = () => {
|
|||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
|
<th
|
||||||
|
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
|
||||||
|
title="Dados que ainda faltam para fechar o plano de corte do SKU."
|
||||||
|
>
|
||||||
|
Dados pendentes
|
||||||
|
</th>
|
||||||
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -895,10 +917,7 @@ const Cutting = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<span className="inline-flex min-w-0 max-w-[92px] items-center gap-1.5 rounded-full border border-sky-400/25 bg-sky-400/10 px-2 py-1 text-xs font-bold text-sky-300">
|
<ProductColorBadge label={row.color} className="max-w-[92px] px-2" />
|
||||||
<Palette className="h-3 w-3 shrink-0" />
|
|
||||||
<span className="truncate">{row.color || '-'}</span>
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
|
||||||
<Ruler className="h-3 w-3" />
|
<Ruler className="h-3 w-3" />
|
||||||
{row.size || '-'}
|
{row.size || '-'}
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import { DollarSign, Package, Palette, Ruler, TrendingDown, TrendingUp, Warehous
|
|||||||
import BackButton from '../components/BackButton';
|
import BackButton from '../components/BackButton';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
|
import ProductColorBadge, { ProductColorSwatch, getProductColor } from '../components/ProductColorBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { fetchProductAnalytics } from '../dataService';
|
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||||
|
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
|
|
||||||
type VariantRow = ProductAnalyticsItem & {
|
type VariantRow = ProductAnalyticsItem & {
|
||||||
color: string;
|
color: string;
|
||||||
size: string;
|
size: string;
|
||||||
dailySales: number;
|
dailySales: number;
|
||||||
|
projectedDemand: number;
|
||||||
|
openProductionQuantity: number;
|
||||||
|
availableQuantity: number;
|
||||||
|
suggestedReplenishment: number;
|
||||||
daysOfCover: number | null;
|
daysOfCover: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,25 +31,12 @@ type BreakdownRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BREAKDOWN_LIMIT = 12;
|
const BREAKDOWN_LIMIT = 12;
|
||||||
|
const REPLENISHMENT_TARGET_DAYS = 30;
|
||||||
|
|
||||||
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
|
const allProductionOrdersRange = {
|
||||||
{ pattern: 'preto', color: '#171717' },
|
start: new Date(2000, 0, 1),
|
||||||
{ pattern: 'branco', color: '#f8fafc' },
|
end: new Date(2100, 11, 31)
|
||||||
{ pattern: 'bege', color: '#d7bf9a' },
|
};
|
||||||
{ pattern: 'café', color: '#79553d' },
|
|
||||||
{ pattern: 'cafe', color: '#79553d' },
|
|
||||||
{ pattern: 'perola', color: '#e7dfcf' },
|
|
||||||
{ pattern: 'pérola', color: '#e7dfcf' },
|
|
||||||
{ pattern: 'marinho', color: '#172554' },
|
|
||||||
{ pattern: 'bordo', color: '#6b1226' },
|
|
||||||
{ pattern: 'bordô', color: '#6b1226' },
|
|
||||||
{ pattern: 'verde', color: '#166534' },
|
|
||||||
{ pattern: 'rosa', color: '#f0a6bf' },
|
|
||||||
{ pattern: 'cinza', color: '#8f8f8f' },
|
|
||||||
{ pattern: 'vermelho', color: '#b91c1c' },
|
|
||||||
{ pattern: 'grafite', color: '#3f3f46' },
|
|
||||||
{ pattern: 'azul', color: '#2563eb' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const getRangeDays = (range: DateRange) => {
|
const getRangeDays = (range: DateRange) => {
|
||||||
const start = new Date(range.start);
|
const start = new Date(range.start);
|
||||||
@@ -67,26 +60,11 @@ const formatDays = (value: number | null) => {
|
|||||||
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
|
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSwatchColor = (label: string) => {
|
|
||||||
const normalizedLabel = label.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
|
||||||
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern.normalize('NFD').replace(/\p{Diacritic}/gu, '')))?.color || '#64748b';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBarColor = (label: string) => {
|
const getBarColor = (label: string) => {
|
||||||
const color = getSwatchColor(label);
|
const color = getProductColor(label);
|
||||||
return `color-mix(in srgb, ${color} 74%, var(--color-dark-text) 26%)`;
|
return `color-mix(in srgb, ${color} 74%, var(--color-dark-text) 26%)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ColorSwatch = ({ label, className = 'h-2.5 w-2.5' }: { label: string; className?: string }) => (
|
|
||||||
<span
|
|
||||||
className={`${className} shrink-0 rounded-sm`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: getSwatchColor(label),
|
|
||||||
boxShadow: '0 0 0 1px var(--color-dark-card), 0 0 0 2px var(--color-dark-border)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
|
const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
|
||||||
const totals = new Map<string, BreakdownRow>();
|
const totals = new Map<string, BreakdownRow>();
|
||||||
|
|
||||||
@@ -147,7 +125,7 @@ const BreakdownPanel = ({
|
|||||||
return (
|
return (
|
||||||
<div key={row.label} className="grid grid-cols-[minmax(7.5rem,9rem)_1fr_6rem] items-center gap-3">
|
<div key={row.label} className="grid grid-cols-[minmax(7.5rem,9rem)_1fr_6rem] items-center gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{type === 'color' ? <ColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />}
|
{type === 'color' ? <ProductColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />}
|
||||||
<span className="truncate text-xs font-bold text-dark-text" title={row.label}>
|
<span className="truncate text-xs font-bold text-dark-text" title={row.label}>
|
||||||
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label}
|
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -207,6 +185,7 @@ const ProductGroupDetails = () => {
|
|||||||
setDateRange: (range: DateRange) => void
|
setDateRange: (range: DateRange) => void
|
||||||
}>();
|
}>();
|
||||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||||
|
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
@@ -226,10 +205,14 @@ const ProductGroupDetails = () => {
|
|||||||
|
|
||||||
const loadProducts = async () => {
|
const loadProducts = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data = await fetchProductAnalytics(dateRange);
|
const [productData, productionOrderData] = await Promise.all([
|
||||||
|
fetchProductAnalytics(dateRange),
|
||||||
|
fetchProductionOrders(allProductionOrdersRange)
|
||||||
|
]);
|
||||||
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setProducts(data);
|
setProducts(productData);
|
||||||
|
setProductionOrders(productionOrderData.orders);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -241,6 +224,11 @@ const ProductGroupDetails = () => {
|
|||||||
};
|
};
|
||||||
}, [dateRange]);
|
}, [dateRange]);
|
||||||
|
|
||||||
|
const openProductionByProductId = useMemo(
|
||||||
|
() => buildOpenProductionByProductId(products, productionOrders),
|
||||||
|
[products, productionOrders]
|
||||||
|
);
|
||||||
|
|
||||||
const groupRows = useMemo<VariantRow[]>(() => {
|
const groupRows = useMemo<VariantRow[]>(() => {
|
||||||
const rangeDays = getRangeDays(dateRange);
|
const rangeDays = getRangeDays(dateRange);
|
||||||
const normalizedGroupName = normalizeProductText(groupName).toLowerCase();
|
const normalizedGroupName = normalizeProductText(groupName).toLowerCase();
|
||||||
@@ -249,6 +237,10 @@ const ProductGroupDetails = () => {
|
|||||||
.map(product => {
|
.map(product => {
|
||||||
const metadata = parseProductName(product.name);
|
const metadata = parseProductName(product.name);
|
||||||
const dailySales = product.quantitySold / rangeDays;
|
const dailySales = product.quantitySold / rangeDays;
|
||||||
|
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
||||||
|
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||||
|
const availableQuantity = product.stock + openProductionQuantity;
|
||||||
|
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
@@ -256,19 +248,27 @@ const ProductGroupDetails = () => {
|
|||||||
size: metadata.size,
|
size: metadata.size,
|
||||||
baseName: metadata.baseName,
|
baseName: metadata.baseName,
|
||||||
dailySales,
|
dailySales,
|
||||||
daysOfCover: dailySales > 0 ? product.stock / dailySales : null
|
projectedDemand,
|
||||||
|
openProductionQuantity,
|
||||||
|
availableQuantity,
|
||||||
|
suggestedReplenishment,
|
||||||
|
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
||||||
.sort((a, b) => b.quantitySold - a.quantitySold);
|
.sort((a, b) => b.quantitySold - a.quantitySold);
|
||||||
}, [dateRange, groupName, products]);
|
}, [dateRange, groupName, openProductionByProductId, products]);
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
|
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
|
||||||
const totalRevenue = groupRows.reduce((total, row) => total + row.revenue, 0);
|
const totalRevenue = groupRows.reduce((total, row) => total + row.revenue, 0);
|
||||||
const totalStock = groupRows.reduce((total, row) => total + row.stock, 0);
|
const totalStock = groupRows.reduce((total, row) => total + row.stock, 0);
|
||||||
|
const openProductionQuantity = groupRows.reduce((total, row) => total + row.openProductionQuantity, 0);
|
||||||
|
const availableQuantity = groupRows.reduce((total, row) => total + row.availableQuantity, 0);
|
||||||
const dailySales = groupRows.reduce((total, row) => total + row.dailySales, 0);
|
const dailySales = groupRows.reduce((total, row) => total + row.dailySales, 0);
|
||||||
const daysOfCover = dailySales > 0 ? totalStock / dailySales : null;
|
const projectedDemand = groupRows.reduce((total, row) => total + row.projectedDemand, 0);
|
||||||
|
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
|
||||||
|
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||||
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
|
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
|
||||||
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
|
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
|
||||||
|
|
||||||
@@ -276,7 +276,11 @@ const ProductGroupDetails = () => {
|
|||||||
totalSold,
|
totalSold,
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
totalStock,
|
totalStock,
|
||||||
|
openProductionQuantity,
|
||||||
|
availableQuantity,
|
||||||
dailySales,
|
dailySales,
|
||||||
|
projectedDemand,
|
||||||
|
suggestedReplenishment,
|
||||||
daysOfCover,
|
daysOfCover,
|
||||||
colorCount: colors.size,
|
colorCount: colors.size,
|
||||||
sizeCount: sizes.size
|
sizeCount: sizes.size
|
||||||
@@ -285,6 +289,12 @@ const ProductGroupDetails = () => {
|
|||||||
|
|
||||||
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
||||||
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
||||||
|
const replenishmentDrivers = useMemo(() => (
|
||||||
|
groupRows
|
||||||
|
.filter(row => row.suggestedReplenishment > 0)
|
||||||
|
.sort((a, b) => b.suggestedReplenishment - a.suggestedReplenishment)
|
||||||
|
.slice(0, 5)
|
||||||
|
), [groupRows]);
|
||||||
const isRefreshing = isLoading && products.length > 0;
|
const isRefreshing = isLoading && products.length > 0;
|
||||||
const totalPages = Math.ceil(groupRows.length / itemsPerPage);
|
const totalPages = Math.ceil(groupRows.length / itemsPerPage);
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
@@ -379,6 +389,78 @@ const ProductGroupDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||||
|
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-dark-text">Contexto de reposição</h2>
|
||||||
|
<p className="mt-1 text-sm font-medium text-dark-muted">
|
||||||
|
Projeção para {REPLENISHMENT_TARGET_DAYS} dias usando vendas do período, estoque atual e OP aberta quando encontrada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`w-fit rounded-full border px-3 py-1 text-xs font-bold ${totals.suggestedReplenishment > 0 ? 'border-red-400/30 bg-red-400/10 text-red-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'}`}>
|
||||||
|
{totals.suggestedReplenishment > 0 ? 'Com necessidade' : 'Coberto'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Sugestão</p>
|
||||||
|
<p className={`mt-2 text-2xl font-bold ${totals.suggestedReplenishment > 0 ? 'text-red-300' : 'text-emerald-300'}`}>
|
||||||
|
{formatNumber(totals.suggestedReplenishment)} un.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Demanda 30 dias</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.projectedDemand, 1)} un.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Disponível</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.availableQuantity)} un.</p>
|
||||||
|
{!!totals.openProductionQuantity && (
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Inclui OP {formatNumber(totals.openProductionQuantity)} un.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatDays(totals.daysOfCover)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/35">
|
||||||
|
<div className="border-b border-dark-border px-4 py-3">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Principais drivers</h3>
|
||||||
|
</div>
|
||||||
|
{replenishmentDrivers.length ? (
|
||||||
|
<div className="divide-y divide-dark-border">
|
||||||
|
{replenishmentDrivers.map(row => (
|
||||||
|
<div key={row.id} className="grid grid-cols-[1fr_auto] gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
|
||||||
|
<span className="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
|
||||||
|
{row.size || 'Sem tamanho'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-dark-muted">
|
||||||
|
Cobertura {formatDays(row.daysOfCover)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-bold text-red-300">{formatNumber(row.suggestedReplenishment)} un.</p>
|
||||||
|
<p className="mt-1 text-[10px] font-semibold text-dark-muted">sugerido</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-6 text-sm font-semibold text-dark-muted">
|
||||||
|
Nenhum SKU do grupo está abaixo da cobertura projetada.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<BreakdownPanel
|
<BreakdownPanel
|
||||||
title="Venda por cor"
|
title="Venda por cor"
|
||||||
@@ -439,10 +521,7 @@ const ProductGroupDetails = () => {
|
|||||||
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</div>
|
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5">
|
<td className="px-6 py-2.5">
|
||||||
<span className="inline-flex max-w-full items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
|
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
|
||||||
<ColorSwatch label={row.color || 'Sem cor'} className="h-2 w-2" />
|
|
||||||
<span className="truncate">{row.color || '-'}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5">
|
<td className="px-6 py-2.5">
|
||||||
<span className="inline-flex rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
|
<span className="inline-flex rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import RefreshStatus from '../components/RefreshStatus';
|
|||||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||||
import { parseProductName, sortProductSizes } from '../productParsing';
|
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||||
|
|
||||||
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
|
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
|
||||||
type ReplenishmentFilter = 'all' | ReplenishmentStatus;
|
type ReplenishmentFilter = 'all' | ReplenishmentStatus;
|
||||||
@@ -568,10 +568,10 @@ const Replenishment = () => {
|
|||||||
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatDays(row.daysOfCover)}</td>
|
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatDays(row.daysOfCover)}</td>
|
||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2.5 text-right">
|
||||||
<Link
|
<Link
|
||||||
to={`/products/${row.id}`}
|
to={viewMode === 'group' ? `/products/groups/${encodeProductGroupKey(row.baseName)}` : `/products/${row.id}`}
|
||||||
className="inline-flex items-center whitespace-nowrap text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
|
className="inline-flex items-center whitespace-nowrap text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
|
||||||
>
|
>
|
||||||
{viewMode === 'group' ? 'Ver líder' : 'Ver produto'}
|
{viewMode === 'group' ? 'Ver grupo' : 'Ver produto'}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default defineConfig(({ command }) => ({
|
|||||||
tailwindcss()
|
tailwindcss()
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 3001,
|
port: 3002,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3004',
|
target: 'http://localhost:3004',
|
||||||
@@ -34,7 +34,7 @@ export default defineConfig(({ command }) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
port: 3000,
|
port: 3002,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3004',
|
target: 'http://localhost:3004',
|
||||||
|
|||||||
Reference in New Issue
Block a user