feat: finalizar Tablero CDC con multipais, Fulgencio y tarifario
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,12 +21,34 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertCircle, Loader2, Lock, DollarSign, Calendar, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Lock,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Trash2,
|
||||
Search,
|
||||
History,
|
||||
MessageSquarePlus,
|
||||
Clock3,
|
||||
} from "lucide-react";
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
import { LinkList } from "./LinkList";
|
||||
import { SearchableSelect } from "./SearchableSelect";
|
||||
import { SearchableMultiSelect } from "./SearchableMultiSelect";
|
||||
import { ProjectPricingPanel } from "./ProjectPricingPanel";
|
||||
import { MESES, type Status } from "@/data/lists";
|
||||
import { useAppLists } from "@/lib/appLists";
|
||||
import { useProjects, type Project } from "@/lib/store";
|
||||
import {
|
||||
addProjectActivityNote,
|
||||
loadProjectActivities,
|
||||
loadProjectPricingItems,
|
||||
useProjects,
|
||||
type Project,
|
||||
type ProjectActivity,
|
||||
type ProjectPricingItemInput,
|
||||
} from "@/lib/store";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { colorHex } from "@/lib/colors";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -47,6 +69,149 @@ interface Props {
|
||||
}
|
||||
|
||||
const WALMART_CONNECT_CLIENT = "Walmart Connect WMC";
|
||||
const ACTIVITY_PAGE_SIZE = 15;
|
||||
|
||||
function formatActivityDate(timestamp: number) {
|
||||
return new Intl.DateTimeFormat("es-DO", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function formatActivityDescription(description: string) {
|
||||
return description
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getPricingTotal(items: ProjectPricingItemInput[]) {
|
||||
return items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||
}
|
||||
|
||||
function toPricingInputItems(
|
||||
items: Awaited<ReturnType<typeof loadProjectPricingItems>>,
|
||||
): ProjectPricingItemInput[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
source: item.source,
|
||||
category: item.category,
|
||||
serviceName: item.serviceName,
|
||||
workType: item.workType,
|
||||
complexityLevel: item.complexityLevel,
|
||||
referenceLabel: item.referenceLabel,
|
||||
referenceMin: item.referenceMin,
|
||||
referenceMax: item.referenceMax,
|
||||
amount: item.amount,
|
||||
description: item.description,
|
||||
}));
|
||||
}
|
||||
|
||||
function hasManualInternalAmountOverride(
|
||||
savedAmount: number | null | undefined,
|
||||
items: ProjectPricingItemInput[],
|
||||
) {
|
||||
if (!items.length || savedAmount == null) return false;
|
||||
|
||||
const cleanSavedAmount = Number(savedAmount);
|
||||
|
||||
if (!Number.isFinite(cleanSavedAmount) || cleanSavedAmount <= 0) return false;
|
||||
|
||||
const calculatedTotal = getPricingTotal(items);
|
||||
|
||||
return Math.abs(cleanSavedAmount - calculatedTotal) > 0.01;
|
||||
}
|
||||
|
||||
function splitBrandValues(value: string | null | undefined, knownBrands: string[] = []) {
|
||||
const cleanValue = canonicalOptionLabel(value);
|
||||
|
||||
if (!cleanValue) return [];
|
||||
|
||||
const knownByKey = new Map(
|
||||
dedupeOptions(knownBrands).map((brand) => [normalizeOptionKey(brand), brand] as const),
|
||||
);
|
||||
const exactKnownBrand = knownByKey.get(normalizeOptionKey(cleanValue));
|
||||
|
||||
if (exactKnownBrand) {
|
||||
return [exactKnownBrand];
|
||||
}
|
||||
|
||||
const parts = cleanValue
|
||||
.split(/[,;]/)
|
||||
.map((brand) => canonicalOptionLabel(brand))
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return dedupeOptions([exactKnownBrand || cleanValue]);
|
||||
}
|
||||
|
||||
const parsedBrands: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < parts.length) {
|
||||
let matchedBrand = "";
|
||||
let nextIndex = index + 1;
|
||||
|
||||
for (let end = parts.length; end > index; end -= 1) {
|
||||
const candidate = parts.slice(index, end).join(", ");
|
||||
const knownBrand = knownByKey.get(normalizeOptionKey(candidate));
|
||||
|
||||
if (knownBrand) {
|
||||
matchedBrand = knownBrand;
|
||||
nextIndex = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
parsedBrands.push(matchedBrand || parts[index]);
|
||||
index = nextIndex;
|
||||
}
|
||||
|
||||
return dedupeOptions(parsedBrands);
|
||||
}
|
||||
|
||||
function joinBrandValues(values: string[]) {
|
||||
return dedupeOptions(values.map((brand) => canonicalOptionLabel(brand)).filter(Boolean)).join(
|
||||
", ",
|
||||
);
|
||||
}
|
||||
|
||||
function splitCountryValues(value: string | null | undefined, knownCountries: string[] = []) {
|
||||
const cleanValue = canonicalOptionLabel(value);
|
||||
|
||||
if (!cleanValue) return [];
|
||||
|
||||
const knownByKey = new Map(
|
||||
dedupeOptions(knownCountries).map((country) => [normalizeOptionKey(country), country] as const),
|
||||
);
|
||||
const exactKnownCountry = knownByKey.get(normalizeOptionKey(cleanValue));
|
||||
|
||||
if (exactKnownCountry) {
|
||||
return [exactKnownCountry];
|
||||
}
|
||||
|
||||
const parts = cleanValue
|
||||
.split(/[,;]/)
|
||||
.map((country) => canonicalOptionLabel(country))
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return dedupeOptions([exactKnownCountry || cleanValue]);
|
||||
}
|
||||
|
||||
return dedupeOptions(
|
||||
parts.map((country) => knownByKey.get(normalizeOptionKey(country)) || country),
|
||||
);
|
||||
}
|
||||
|
||||
function joinCountryValues(values: string[]) {
|
||||
return dedupeOptions(values.map((country) => canonicalOptionLabel(country)).filter(Boolean)).join(
|
||||
", ",
|
||||
);
|
||||
}
|
||||
|
||||
function isWalmartConnectClient(client: string | null | undefined) {
|
||||
return normalizeOptionKey(client) === normalizeOptionKey(WALMART_CONNECT_CLIENT);
|
||||
@@ -76,19 +241,37 @@ const empty = (): Omit<Project, "id" | "createdAt"> => {
|
||||
export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting = false }: Props) {
|
||||
const { add, update } = useProjects();
|
||||
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
|
||||
const { isGerardo, canDeleteProjects } = useAuth();
|
||||
const { isGerardo, canDeleteProjects, canManageInternalPricing } = useAuth();
|
||||
const isEdit = !!project;
|
||||
|
||||
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
|
||||
const [selectedWmcCountries, setSelectedWmcCountries] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [wmcCountrySearch, setWmcCountrySearch] = useState("");
|
||||
const [savingStep, setSavingStep] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [activityItems, setActivityItems] = useState<ProjectActivity[]>([]);
|
||||
const [activityLoading, setActivityLoading] = useState(false);
|
||||
const [activityError, setActivityError] = useState<string | null>(null);
|
||||
const [activityNote, setActivityNote] = useState("");
|
||||
const [activityHasMore, setActivityHasMore] = useState(false);
|
||||
const [activityLoadingMore, setActivityLoadingMore] = useState(false);
|
||||
const [addingActivityNote, setAddingActivityNote] = useState(false);
|
||||
const [pricingItems, setPricingItems] = useState<ProjectPricingItemInput[]>([]);
|
||||
const [pricingLoading, setPricingLoading] = useState(false);
|
||||
const [pricingError, setPricingError] = useState<string | null>(null);
|
||||
const [internalAmountManuallyEdited, setInternalAmountManuallyEdited] = useState(false);
|
||||
|
||||
const selectedBrands = splitBrandValues(form.marca, lists.marcas);
|
||||
const selectedCountries = splitCountryValues(form.bu, lists.bus);
|
||||
const clienteOptions = dedupeOptions(ensureOption(lists.clientes, form.cliente));
|
||||
const marcaOptions = dedupeOptions(ensureOption(lists.marcas, form.marca));
|
||||
const buOptions = dedupeOptions(ensureOption(lists.bus, form.bu));
|
||||
const marcaOptions = dedupeOptions([...lists.marcas, ...selectedBrands]);
|
||||
const buOptions = dedupeOptions([...lists.bus, ...selectedCountries]);
|
||||
const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[];
|
||||
const filteredWmcBuOptions = buOptions.filter((b) =>
|
||||
normalizeOptionKey(b).includes(normalizeOptionKey(wmcCountrySearch)),
|
||||
);
|
||||
const pricingTotal = getPricingTotal(pricingItems);
|
||||
|
||||
useEffect(() => {
|
||||
setFormError(null);
|
||||
@@ -102,15 +285,129 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
bu: cleanBu,
|
||||
});
|
||||
setSelectedWmcCountries(cleanBu ? [cleanBu] : []);
|
||||
setWmcCountrySearch("");
|
||||
setActivityItems([]);
|
||||
setActivityHasMore(false);
|
||||
setActivityError(null);
|
||||
setActivityNote("");
|
||||
setPricingItems([]);
|
||||
setPricingError(null);
|
||||
setInternalAmountManuallyEdited(false);
|
||||
} else if (open) {
|
||||
setForm(empty());
|
||||
setSelectedWmcCountries([]);
|
||||
setWmcCountrySearch("");
|
||||
setActivityItems([]);
|
||||
setActivityHasMore(false);
|
||||
setActivityError(null);
|
||||
setActivityNote("");
|
||||
setPricingItems([]);
|
||||
setPricingError(null);
|
||||
setInternalAmountManuallyEdited(false);
|
||||
}
|
||||
}, [project, open]);
|
||||
|
||||
const refreshActivity = useCallback(async () => {
|
||||
if (!project?.id) return;
|
||||
|
||||
try {
|
||||
setActivityLoading(true);
|
||||
setActivityError(null);
|
||||
const items = await loadProjectActivities(project.id, ACTIVITY_PAGE_SIZE + 1);
|
||||
setActivityItems(items.slice(0, ACTIVITY_PAGE_SIZE));
|
||||
setActivityHasMore(items.length > ACTIVITY_PAGE_SIZE);
|
||||
} catch (error) {
|
||||
console.warn("No se pudo cargar el historial del proyecto:", error);
|
||||
setActivityError(
|
||||
"No se pudo cargar el historial. Revisa que el SQL de actividad esté ejecutado en Supabase.",
|
||||
);
|
||||
setActivityItems([]);
|
||||
setActivityHasMore(false);
|
||||
} finally {
|
||||
setActivityLoading(false);
|
||||
}
|
||||
}, [project?.id]);
|
||||
|
||||
const loadMoreActivity = useCallback(async () => {
|
||||
if (!project?.id || activityLoadingMore || !activityHasMore) return;
|
||||
|
||||
try {
|
||||
setActivityLoadingMore(true);
|
||||
setActivityError(null);
|
||||
const items = await loadProjectActivities(
|
||||
project.id,
|
||||
ACTIVITY_PAGE_SIZE + 1,
|
||||
activityItems.length,
|
||||
);
|
||||
setActivityItems((current) => [...current, ...items.slice(0, ACTIVITY_PAGE_SIZE)]);
|
||||
setActivityHasMore(items.length > ACTIVITY_PAGE_SIZE);
|
||||
} catch (error) {
|
||||
console.warn("No se pudo cargar más historial del proyecto:", error);
|
||||
setActivityError("No se pudo cargar más actividad. Inténtalo nuevamente.");
|
||||
} finally {
|
||||
setActivityLoadingMore(false);
|
||||
}
|
||||
}, [activityHasMore, activityItems.length, activityLoadingMore, project?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !project?.id) return;
|
||||
|
||||
void refreshActivity();
|
||||
}, [open, project?.id, refreshActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !project?.id || !canManageInternalPricing) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadPricing = async () => {
|
||||
try {
|
||||
setPricingLoading(true);
|
||||
setPricingError(null);
|
||||
const items = await loadProjectPricingItems(project.id);
|
||||
const inputItems = toPricingInputItems(items);
|
||||
|
||||
if (!cancelled) {
|
||||
setInternalAmountManuallyEdited(
|
||||
hasManualInternalAmountOverride(project.monto, inputItems),
|
||||
);
|
||||
setPricingItems(inputItems);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("No se pudo cargar el tarifario del proyecto:", error);
|
||||
|
||||
if (!cancelled) {
|
||||
setPricingItems([]);
|
||||
setPricingError(
|
||||
"No se pudieron cargar los costos guardados. Revisa que el SQL del tarifario esté ejecutado en Supabase.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setPricingLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadPricing();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canManageInternalPricing, open, project?.id, project?.monto]);
|
||||
|
||||
const set = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManageInternalPricing || !pricingItems.length || internalAmountManuallyEdited) return;
|
||||
|
||||
const nextAmount = Number(pricingTotal.toFixed(2));
|
||||
setForm((current) =>
|
||||
current.monto === nextAmount ? current : { ...current, monto: nextAmount },
|
||||
);
|
||||
}, [canManageInternalPricing, internalAmountManuallyEdited, pricingItems.length, pricingTotal]);
|
||||
|
||||
const getCountryManager = (bu: string) => {
|
||||
const direct = lists.buCm[bu];
|
||||
if (direct) return direct;
|
||||
@@ -122,9 +419,17 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
};
|
||||
|
||||
// BU → Country Manager automático desde Supabase tablero_cdc_app_lists, con fallback local.
|
||||
const onBU = (bu: string) => {
|
||||
const cleanBu = canonicalOptionLabel(bu);
|
||||
setForm((f) => ({ ...f, bu: cleanBu, cm: getCountryManager(cleanBu) }));
|
||||
const onCountries = (countries: string[]) => {
|
||||
const cleanCountries = dedupeOptions(
|
||||
countries.map((country) => canonicalOptionLabel(country)).filter(Boolean),
|
||||
);
|
||||
const managers = dedupeOptions(cleanCountries.map(getCountryManager).filter(Boolean));
|
||||
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
bu: joinCountryValues(cleanCountries),
|
||||
cm: managers.join(", "),
|
||||
}));
|
||||
};
|
||||
|
||||
const onCliente = (cliente: string) => {
|
||||
@@ -142,10 +447,13 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
|
||||
if (!isWalmartConnectClient(cleanCliente)) {
|
||||
setSelectedWmcCountries([]);
|
||||
setWmcCountrySearch("");
|
||||
}
|
||||
};
|
||||
|
||||
const isWalmartConnectCreate = !isEdit && isWalmartConnectClient(form.cliente);
|
||||
const selectedCreateCountries = isWalmartConnectCreate ? selectedWmcCountries : selectedCountries;
|
||||
const isMultiCountryCreate = !isEdit && selectedCreateCountries.length > 1;
|
||||
|
||||
const toggleWmcCountry = (country: string, checked: boolean) => {
|
||||
const cleanCountry = canonicalOptionLabel(country);
|
||||
@@ -165,13 +473,17 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
country,
|
||||
manager: getCountryManager(country),
|
||||
}));
|
||||
const selectedCountryManagers = selectedCountries.map((country) => ({
|
||||
country,
|
||||
manager: getCountryManager(country),
|
||||
}));
|
||||
|
||||
const requiredOk =
|
||||
form.nombre.trim() &&
|
||||
form.cliente &&
|
||||
form.marca &&
|
||||
form.solicitante.trim() &&
|
||||
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : form.bu);
|
||||
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : selectedCountries.length > 0);
|
||||
|
||||
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
|
||||
if (result.sheetSyncStatus === "synced") {
|
||||
@@ -193,30 +505,67 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
});
|
||||
};
|
||||
|
||||
const addActivityNote = async () => {
|
||||
if (!project?.id || !activityNote.trim() || addingActivityNote) return;
|
||||
|
||||
try {
|
||||
setAddingActivityNote(true);
|
||||
const result = await addProjectActivityNote(project.id, activityNote);
|
||||
set("comentarios", result.comments);
|
||||
setActivityNote("");
|
||||
await refreshActivity();
|
||||
|
||||
if (result.sheetSyncStatus === "synced") {
|
||||
toast.success("Comentario agregado al historial", {
|
||||
description: "También se actualizó la columna Comentarios del Google Sheet.",
|
||||
});
|
||||
} else if (result.sheetSyncStatus === "skipped") {
|
||||
toast.success("Comentario agregado al historial", {
|
||||
description:
|
||||
"Se guardó como comentario interno. La sincronización con Sheet no está configurada.",
|
||||
});
|
||||
} else {
|
||||
toast.warning("Comentario agregado con aviso", {
|
||||
description: result.sheetSyncMessage,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error agregando comentario al historial:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "No se pudo agregar el comentario al historial.";
|
||||
toast.error("No se pudo agregar el comentario", { description: message });
|
||||
} finally {
|
||||
setAddingActivityNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!requiredOk || saving) return;
|
||||
|
||||
const safeForm = isGerardo
|
||||
? form
|
||||
: {
|
||||
...form,
|
||||
status: project?.status ?? "",
|
||||
monto: project?.monto ?? null,
|
||||
};
|
||||
: canManageInternalPricing
|
||||
? {
|
||||
...form,
|
||||
status: project?.status ?? "",
|
||||
}
|
||||
: {
|
||||
...form,
|
||||
status: project?.status ?? "",
|
||||
monto: project?.monto ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
setSavingStep("Guardando y sincronizando con Google Sheet…");
|
||||
|
||||
if (isWalmartConnectCreate) {
|
||||
const countries = selectedWmcCountries;
|
||||
if (isMultiCountryCreate) {
|
||||
const countries = selectedCreateCountries;
|
||||
const results = [];
|
||||
|
||||
for (const [index, country] of countries.entries()) {
|
||||
setSavingStep(
|
||||
`Guardando Walmart Connect WMC ${index + 1} de ${countries.length}: ${country}…`,
|
||||
);
|
||||
setSavingStep(`Guardando país ${index + 1} de ${countries.length}: ${country}…`);
|
||||
|
||||
const countryProject = {
|
||||
...safeForm,
|
||||
@@ -226,25 +575,38 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
createdAt: Date.now() + index,
|
||||
};
|
||||
|
||||
results.push(await add(countryProject));
|
||||
results.push(
|
||||
await add({
|
||||
...countryProject,
|
||||
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const failedSyncs = results.filter((result) => result.sheetSyncStatus !== "synced");
|
||||
|
||||
if (failedSyncs.length === 0) {
|
||||
toast.success("Proyectos Walmart Connect creados", {
|
||||
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet.`,
|
||||
toast.success("Proyectos multipaís creados", {
|
||||
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet. El Interno Cargado se aplicó por país.`,
|
||||
});
|
||||
} else {
|
||||
toast.warning("Proyectos Walmart Connect creados con aviso", {
|
||||
toast.warning("Proyectos multipaís creados con aviso", {
|
||||
description: `${failedSyncs.length} de ${countries.length} sincronizaciones con Google Sheet requieren revisión.`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const result =
|
||||
isEdit && project
|
||||
? await update(project.id, safeForm)
|
||||
: await add({ ...safeForm, id: crypto.randomUUID(), createdAt: Date.now() });
|
||||
? await update(project.id, {
|
||||
...safeForm,
|
||||
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||
})
|
||||
: await add({
|
||||
...safeForm,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||
});
|
||||
|
||||
showSaveToast(result);
|
||||
}
|
||||
@@ -360,18 +722,14 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
<Label>
|
||||
Cliente <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={form.cliente} onValueChange={onCliente}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="Seleccionar…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clienteOptions.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableSelect
|
||||
value={form.cliente}
|
||||
options={clienteOptions}
|
||||
onValueChange={onCliente}
|
||||
placeholder="Seleccionar…"
|
||||
searchPlaceholder="Buscar cliente…"
|
||||
emptyText="No se encontró ese cliente."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Marca */}
|
||||
@@ -379,18 +737,15 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
<Label>
|
||||
Marca <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={form.marca} onValueChange={(v) => set("marca", v)}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="Seleccionar…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{marcaOptions.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableMultiSelect
|
||||
values={selectedBrands}
|
||||
options={marcaOptions}
|
||||
onValuesChange={(values) => set("marca", joinBrandValues(values))}
|
||||
placeholder="Seleccionar una o varias marcas…"
|
||||
searchPlaceholder="Buscar marca…"
|
||||
emptyText="No se encontró esa marca."
|
||||
summaryLabel="marcas seleccionadas"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BU */}
|
||||
@@ -406,10 +761,19 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Para Walmart Connect WMC puedes seleccionar varios países. La app creará un
|
||||
proyecto y una fila del Sheet por cada país.
|
||||
proyecto y una fila del Sheet por cada país, como hasta ahora.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={wmcCountrySearch}
|
||||
onChange={(e) => setWmcCountrySearch(e.target.value)}
|
||||
placeholder="Buscar país…"
|
||||
className="h-9 pl-9 bg-card"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{buOptions.map((b) => {
|
||||
{filteredWmcBuOptions.map((b) => {
|
||||
const checked = selectedWmcCountries.some(
|
||||
(country) => normalizeOptionKey(country) === normalizeOptionKey(b),
|
||||
);
|
||||
@@ -442,22 +806,23 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<Label>
|
||||
BU Solicita (País) <span className="text-destructive">*</span>
|
||||
BU Solicita (Países) <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={form.bu} onValueChange={onBU}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="Seleccionar…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{buOptions.map((b) => (
|
||||
<SelectItem key={b} value={b}>
|
||||
{b}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SearchableMultiSelect
|
||||
values={selectedCountries}
|
||||
options={buOptions}
|
||||
onValuesChange={onCountries}
|
||||
placeholder="Seleccionar uno o varios países…"
|
||||
searchPlaceholder="Buscar país…"
|
||||
emptyText="No se encontró ese país."
|
||||
summaryLabel="países seleccionados"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Si seleccionas varios países al crear, la app creará un proyecto y una fila del
|
||||
Sheet por cada país.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -481,16 +846,29 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={form.cm}
|
||||
readOnly
|
||||
disabled
|
||||
className="h-10 bg-muted/50"
|
||||
placeholder="—"
|
||||
/>
|
||||
<div className="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
{selectedCountryManagers.length === 0 ? (
|
||||
<span className="text-muted-foreground">Selecciona uno o más países.</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedCountryManagers.map(({ country, manager }) => (
|
||||
<Badge key={country} variant="secondary" className="font-normal">
|
||||
{country}: {manager || "—"}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMultiCountryCreate && canManageInternalPricing && (
|
||||
<div className="md:col-span-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Si seleccionas varios países, el Interno Cargado se aplicará por cada país. La app
|
||||
no divide el monto automáticamente.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Solicitante */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
@@ -504,16 +882,20 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comentarios */}
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<Label>Comentarios</Label>
|
||||
<Textarea
|
||||
value={form.comentarios}
|
||||
onChange={(e) => set("comentarios", e.target.value)}
|
||||
placeholder="Notas adicionales del proyecto…"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<ProjectActivityPanel
|
||||
isEdit={isEdit}
|
||||
items={activityItems}
|
||||
loading={activityLoading}
|
||||
error={activityError}
|
||||
note={activityNote}
|
||||
hasMore={activityHasMore}
|
||||
loadingMore={activityLoadingMore}
|
||||
addingNote={addingActivityNote}
|
||||
onNoteChange={setActivityNote}
|
||||
onAddNote={addActivityNote}
|
||||
onRefresh={refreshActivity}
|
||||
onLoadMore={loadMoreActivity}
|
||||
/>
|
||||
|
||||
<Separator className="md:col-span-2 my-1" />
|
||||
|
||||
@@ -556,9 +938,20 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
|
||||
<Separator className="md:col-span-2 my-1" />
|
||||
|
||||
{/* Status: Gerardo edita; el equipo solo visualiza */}
|
||||
{canManageInternalPricing && (
|
||||
<ProjectPricingPanel
|
||||
items={pricingItems}
|
||||
onChange={setPricingItems}
|
||||
loading={pricingLoading}
|
||||
error={pricingError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator className="md:col-span-2 my-1" />
|
||||
|
||||
{/* Status: Director Creativo edita; el equipo solo visualiza */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<Label className="flex items-center gap-1.5 pl-1">
|
||||
Estatus
|
||||
{!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />}
|
||||
</Label>
|
||||
@@ -587,28 +980,36 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
)}
|
||||
|
||||
{!isGerardo && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Solo Gerardo Marrero puede cambiar el estatus.
|
||||
<p className="pl-1 text-[11px] text-muted-foreground">
|
||||
Solo el Director Creativo puede cambiar el estatus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monto: SOLO visible para Gerardo Marrero */}
|
||||
{isGerardo && (
|
||||
{/* Monto: SOLO visible para usuarios internos autorizados */}
|
||||
{canManageInternalPricing && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<DollarSign className="w-3.5 h-3.5" /> Interno Cargado
|
||||
<Label className="flex items-center gap-1.5 pl-1">
|
||||
Interno Cargado <DollarSign className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.monto ?? ""}
|
||||
onChange={(e) =>
|
||||
set("monto", e.target.value === "" ? null : Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => {
|
||||
setInternalAmountManuallyEdited(true);
|
||||
set("monto", e.target.value === "" ? null : Number(e.target.value));
|
||||
}}
|
||||
placeholder="0.00"
|
||||
className="h-10"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Privado · solo Gerardo Marrero.</p>
|
||||
<p className="pl-1 text-[11px] text-muted-foreground">
|
||||
Privado · solo usuarios internos autorizados.
|
||||
{internalAmountManuallyEdited && pricingItems.length > 0
|
||||
? " Monto ajustado manualmente; se respetará al guardar."
|
||||
: pricingItems.length > 0
|
||||
? " El total del tarifario se refleja aquí y puedes ajustarlo."
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -654,7 +1055,11 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving || deleting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!requiredOk || saving || deleting}>
|
||||
@@ -663,7 +1068,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
? "Guardando…"
|
||||
: isEdit
|
||||
? "Guardar cambios"
|
||||
: isWalmartConnectCreate
|
||||
: isMultiCountryCreate
|
||||
? "Crear proyectos"
|
||||
: "Crear proyecto"}
|
||||
</Button>
|
||||
@@ -674,3 +1079,173 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectActivityPanel({
|
||||
isEdit,
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
note,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
addingNote,
|
||||
onNoteChange,
|
||||
onAddNote,
|
||||
onRefresh,
|
||||
onLoadMore,
|
||||
}: {
|
||||
isEdit: boolean;
|
||||
items: ProjectActivity[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
note: string;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
addingNote: boolean;
|
||||
onNoteChange: (value: string) => void;
|
||||
onAddNote: () => Promise<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onLoadMore: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="md:col-span-2 rounded-2xl border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<History className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Actividad del proyecto</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comentarios y movimientos recientes con fecha.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void onRefresh()}
|
||||
disabled={loading}
|
||||
className="h-8 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{loading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Actualizar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEdit ? (
|
||||
<div className="rounded-xl border border-dashed border-border bg-card/70 px-3 py-3 text-sm text-muted-foreground">
|
||||
El historial se activará cuando el proyecto sea creado.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-border bg-card p-3">
|
||||
<Label className="text-xs font-medium">Agregar comentario o solicitud adicional</Label>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(event) => onNoteChange(event.target.value)}
|
||||
placeholder="Ej: CM pidió ajustar artes, agregar una marca o cambiar una fecha…"
|
||||
rows={2}
|
||||
className="min-h-[64px] flex-1 resize-none"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onAddNote()}
|
||||
disabled={!note.trim() || addingNote}
|
||||
className="gap-1.5 rounded-full sm:self-end"
|
||||
>
|
||||
{addingNote ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
)}
|
||||
Agregar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Cargando actividad…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-border bg-card px-3 py-3 text-sm text-muted-foreground">
|
||||
Aún no hay actividad registrada. Los próximos comentarios o cambios quedarán aquí.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 px-0.5 text-[11px] text-muted-foreground">
|
||||
<span>Mostrando los movimientos más recientes.</span>
|
||||
<span>{items.length} visibles</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[440px] space-y-2 overflow-y-auto pr-1">
|
||||
{items.map((item) => {
|
||||
const lines = formatActivityDescription(item.description);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border border-border bg-card px-3 py-3 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.actorName}
|
||||
{item.actorEmail ? ` · ${item.actorEmail}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{formatActivityDate(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{lines.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
{lines.map((line) => (
|
||||
<p key={line}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void onLoadMore()}
|
||||
disabled={loadingMore}
|
||||
className="rounded-full"
|
||||
>
|
||||
{loadingMore ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
{loadingMore ? "Cargando…" : "Cargar más"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Calculator, DollarSign, Plus, Trash2, Info } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
||||
import {
|
||||
TARIFF_CATALOG,
|
||||
TARIFF_SECTIONS,
|
||||
defaultTariffAmount,
|
||||
formatTariffRange,
|
||||
type TariffCatalogItem,
|
||||
type TariffLevel,
|
||||
type TariffWorkType,
|
||||
} from "@/data/tariff";
|
||||
import type { ProjectPricingItemInput } from "@/lib/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
items: ProjectPricingItemInput[];
|
||||
onChange: (items: ProjectPricingItemInput[]) => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function parseAmount(value: string) {
|
||||
const parsed = Number(value.replace(/,/g, "").trim());
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value || 0);
|
||||
}
|
||||
|
||||
function findDefaultWorkType(item?: TariffCatalogItem | null) {
|
||||
return item?.workTypes[0] || null;
|
||||
}
|
||||
|
||||
function findDefaultLevel(workType?: TariffWorkType | null) {
|
||||
return workType?.levels[0] || null;
|
||||
}
|
||||
|
||||
function clean(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function ProjectPricingPanel({ items, onChange, loading, error }: Props) {
|
||||
const [section, setSection] = useState<string>("grafico");
|
||||
const [serviceId, setServiceId] = useState<string>("");
|
||||
const [workTypeId, setWorkTypeId] = useState<string>("");
|
||||
const [levelId, setLevelId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
|
||||
const [manualName, setManualName] = useState<string>("");
|
||||
const [manualDescription, setManualDescription] = useState<string>("");
|
||||
const [manualAmount, setManualAmount] = useState<string>("");
|
||||
|
||||
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
|
||||
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
|
||||
|
||||
const serviceOptions = useMemo(
|
||||
() =>
|
||||
TARIFF_CATALOG.filter((item) => item.section === section).map(
|
||||
(item) => `${item.category} · ${item.service}`,
|
||||
),
|
||||
[section],
|
||||
);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
const selectedLabel = serviceId;
|
||||
return (
|
||||
TARIFF_CATALOG.find(
|
||||
(item) =>
|
||||
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
|
||||
) || null
|
||||
);
|
||||
}, [section, serviceId]);
|
||||
|
||||
const selectedWorkType = useMemo(() => {
|
||||
if (!selectedItem) return null;
|
||||
return selectedItem.workTypes.find((workType) => workType.id === workTypeId) || null;
|
||||
}, [selectedItem, workTypeId]);
|
||||
|
||||
const selectedLevel = useMemo(() => {
|
||||
if (!selectedWorkType) return null;
|
||||
return selectedWorkType.levels.find((level) => level.id === levelId) || null;
|
||||
}, [selectedWorkType, levelId]);
|
||||
|
||||
const total = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||
|
||||
const resetTariffSelection = () => {
|
||||
setServiceId("");
|
||||
setWorkTypeId("");
|
||||
setLevelId("");
|
||||
setAmount("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const onSectionChange = (label: string) => {
|
||||
const option = TARIFF_SECTIONS.find((item) => item.label === label);
|
||||
setSection(option?.id || "grafico");
|
||||
resetTariffSelection();
|
||||
};
|
||||
|
||||
const onServiceChange = (label: string) => {
|
||||
setServiceId(label);
|
||||
const item = TARIFF_CATALOG.find(
|
||||
(catalogItem) =>
|
||||
catalogItem.section === section &&
|
||||
`${catalogItem.category} · ${catalogItem.service}` === label,
|
||||
);
|
||||
const firstWorkType = findDefaultWorkType(item);
|
||||
const firstLevel = findDefaultLevel(firstWorkType);
|
||||
|
||||
setWorkTypeId(firstWorkType?.id || "");
|
||||
setLevelId(firstLevel?.id || "");
|
||||
setAmount(defaultTariffAmount(firstLevel));
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const onWorkTypeChange = (label: string) => {
|
||||
if (!selectedItem) return;
|
||||
const workType = selectedItem.workTypes.find((item) => item.label === label);
|
||||
const firstLevel = findDefaultLevel(workType);
|
||||
|
||||
setWorkTypeId(workType?.id || "");
|
||||
setLevelId(firstLevel?.id || "");
|
||||
setAmount(defaultTariffAmount(firstLevel));
|
||||
};
|
||||
|
||||
const onLevelChange = (label: string) => {
|
||||
if (!selectedWorkType) return;
|
||||
const level = selectedWorkType.levels.find((item) => item.label === label);
|
||||
setLevelId(level?.id || "");
|
||||
setAmount(defaultTariffAmount(level));
|
||||
};
|
||||
|
||||
const addTariffItem = () => {
|
||||
if (!selectedItem || !selectedWorkType || !selectedLevel) return;
|
||||
|
||||
const finalAmount = parseAmount(amount);
|
||||
if (finalAmount <= 0) return;
|
||||
|
||||
onChange([
|
||||
...items,
|
||||
{
|
||||
source: "tariff",
|
||||
category: selectedItem.category,
|
||||
serviceName: selectedItem.service,
|
||||
workType: selectedWorkType.label,
|
||||
complexityLevel: selectedLevel.label,
|
||||
referenceLabel: formatTariffRange(selectedLevel),
|
||||
referenceMin: selectedLevel.min ?? null,
|
||||
referenceMax: selectedLevel.max ?? null,
|
||||
amount: finalAmount,
|
||||
description: clean(description),
|
||||
},
|
||||
]);
|
||||
|
||||
setDescription("");
|
||||
setAmount(defaultTariffAmount(selectedLevel));
|
||||
};
|
||||
|
||||
const addManualItem = () => {
|
||||
const finalAmount = parseAmount(manualAmount);
|
||||
const name = clean(manualName) || "Otros / costo manual";
|
||||
|
||||
if (finalAmount <= 0) return;
|
||||
|
||||
onChange([
|
||||
...items,
|
||||
{
|
||||
source: "manual",
|
||||
category: "Otros",
|
||||
serviceName: name,
|
||||
workType: "Manual",
|
||||
complexityLevel: "",
|
||||
referenceLabel: "Monto manual",
|
||||
referenceMin: null,
|
||||
referenceMax: null,
|
||||
amount: finalAmount,
|
||||
description: clean(manualDescription),
|
||||
},
|
||||
]);
|
||||
|
||||
setManualName("");
|
||||
setManualDescription("");
|
||||
setManualAmount("");
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onChange(items.filter((_, itemIndex) => itemIndex !== index));
|
||||
};
|
||||
|
||||
const selectedWorkTypeLabel = selectedWorkType?.label || "";
|
||||
const selectedLevelLabel = selectedLevel?.label || "";
|
||||
|
||||
return (
|
||||
<div className="md:col-span-2 rounded-2xl border border-border bg-muted/20 p-4">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Calculator className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Tarifario / Estimación interna</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agrega costos del tarifario o montos manuales. El total se guarda como Interno
|
||||
Cargado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
|
||||
Total: {formatCurrency(total)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
|
||||
Cargando costos guardados…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Agregar desde tarifario</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Referencia flexible. El Director Creativo decide el monto final.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Tarifario</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Sección</Label>
|
||||
<SearchableSelect
|
||||
value={selectedSectionLabel}
|
||||
options={sectionOptions}
|
||||
onValueChange={onSectionChange}
|
||||
placeholder="Seleccionar sección…"
|
||||
searchPlaceholder="Buscar sección…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Ítem a cobrar</Label>
|
||||
<SearchableSelect
|
||||
value={serviceId}
|
||||
options={serviceOptions}
|
||||
onValueChange={onServiceChange}
|
||||
placeholder="Seleccionar ítem…"
|
||||
searchPlaceholder="Buscar ítem…"
|
||||
emptyText="No se encontró ese ítem."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tipo de trabajo</Label>
|
||||
<SearchableSelect
|
||||
value={selectedWorkTypeLabel}
|
||||
options={selectedItem?.workTypes.map((workType) => workType.label) || []}
|
||||
onValueChange={onWorkTypeChange}
|
||||
placeholder="Seleccionar tipo…"
|
||||
searchPlaceholder="Buscar tipo…"
|
||||
disabled={!selectedItem}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Nivel / referencia</Label>
|
||||
<SearchableSelect
|
||||
value={selectedLevelLabel}
|
||||
options={selectedWorkType?.levels.map((item) => item.label) || []}
|
||||
onValueChange={onLevelChange}
|
||||
placeholder="Seleccionar nivel…"
|
||||
searchPlaceholder="Buscar nivel…"
|
||||
disabled={!selectedWorkType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedItem && selectedWorkType && selectedLevel && (
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<span className="font-medium text-foreground">Referencia:</span>{" "}
|
||||
{formatTariffRange(selectedLevel)} USD
|
||||
{selectedWorkType.hourReference
|
||||
? ` · Hora hombre ref.: ${selectedWorkType.hourReference}`
|
||||
: ""}
|
||||
</p>
|
||||
{selectedItem.notes && <p className="mt-1">{selectedItem.notes}</p>}
|
||||
{selectedLevel.hint && <p className="mt-1">{selectedLevel.hint}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Monto final</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
placeholder="0.00"
|
||||
disabled={!selectedLevel}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Observación</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Ej: más rondas de cambios, piezas adicionales, complejidad especial…"
|
||||
disabled={!selectedLevel}
|
||||
rows={5}
|
||||
className="min-h-[132px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addTariffItem}
|
||||
disabled={
|
||||
!selectedItem || !selectedWorkType || !selectedLevel || parseAmount(amount) <= 0
|
||||
}
|
||||
className="gap-1.5 rounded-full"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Agregar tarifa
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Agregar costo manual</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Para otros, adicionales o casos fuera del tarifario.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Nombre</Label>
|
||||
<Input
|
||||
value={manualName}
|
||||
onChange={(event) => setManualName(event.target.value)}
|
||||
placeholder="Ej: Piezas adicionales"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Monto</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={manualAmount}
|
||||
onChange={(event) => setManualAmount(event.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Descripción</Label>
|
||||
<Textarea
|
||||
value={manualDescription}
|
||||
onChange={(event) => setManualDescription(event.target.value)}
|
||||
placeholder="Ej: Cliente pidió 8 piezas adicionales fuera del paquete."
|
||||
rows={4}
|
||||
className="min-h-[110px] resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addManualItem}
|
||||
disabled={parseAmount(manualAmount) <= 0}
|
||||
className="w-full gap-1.5 rounded-full sm:w-auto sm:min-w-[240px]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Agregar costo manual
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">Costos agregados</p>
|
||||
<p className="text-xs text-muted-foreground">{items.length} línea(s)</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border bg-card/70 px-3 py-3 text-sm text-muted-foreground">
|
||||
Aún no hay costos agregados. Puedes usar el tarifario, agregar otros manuales o ambos.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.source}-${item.serviceName}-${index}`}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border bg-card px-3 py-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={item.source === "manual" ? "outline" : "secondary"}>
|
||||
{item.source === "manual" ? "Manual" : "Tarifario"}
|
||||
</Badge>
|
||||
<p className="text-sm font-medium">{item.serviceName}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{item.category && <span>{item.category}</span>}
|
||||
{item.workType && <span>{item.workType}</span>}
|
||||
{item.complexityLevel && <span>{item.complexityLevel}</span>}
|
||||
{item.referenceLabel && <span>Ref.: {item.referenceLabel}</span>}
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 sm:flex-col sm:items-end">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{formatCurrency(Number(item.amount || 0))}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeItem(index)}
|
||||
className={cn(
|
||||
"h-8 rounded-full px-2 text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Quitar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 rounded-xl bg-primary/10 px-3 py-2 text-primary">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
Total interno estimado: {formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useMemo, useState, type WheelEvent } from "react";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.scrollTop += event.deltaY;
|
||||
}
|
||||
|
||||
interface SearchableMultiSelectProps {
|
||||
values: string[];
|
||||
options: string[];
|
||||
onValuesChange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
summaryLabel?: string;
|
||||
}
|
||||
|
||||
function cleanValues(values: string[]) {
|
||||
return dedupeOptions(values.map((value) => canonicalOptionLabel(value)).filter(Boolean));
|
||||
}
|
||||
|
||||
export function SearchableMultiSelect({
|
||||
values,
|
||||
options,
|
||||
onValuesChange,
|
||||
placeholder = "Seleccionar…",
|
||||
searchPlaceholder = "Buscar…",
|
||||
emptyText = "No hay resultados.",
|
||||
disabled = false,
|
||||
summaryLabel = "opciones seleccionadas",
|
||||
}: SearchableMultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedValues = useMemo(() => cleanValues(values), [values]);
|
||||
const normalizedSelected = useMemo(
|
||||
() => new Set(selectedValues.map((value) => normalizeOptionKey(value))),
|
||||
[selectedValues],
|
||||
);
|
||||
const cleanOptions = useMemo(
|
||||
() =>
|
||||
dedupeOptions([...options, ...selectedValues].map((option) => canonicalOptionLabel(option))),
|
||||
[options, selectedValues],
|
||||
);
|
||||
|
||||
const selectedSummary = useMemo(() => {
|
||||
if (selectedValues.length === 0) return "";
|
||||
if (selectedValues.length <= 2) return selectedValues.join(", ");
|
||||
return `${selectedValues.length} ${summaryLabel}`;
|
||||
}, [selectedValues, summaryLabel]);
|
||||
|
||||
const updateValues = (nextValues: string[]) => {
|
||||
onValuesChange(cleanValues(nextValues));
|
||||
};
|
||||
|
||||
const toggleOption = (option: string) => {
|
||||
const cleanOption = canonicalOptionLabel(option);
|
||||
const optionKey = normalizeOptionKey(cleanOption);
|
||||
|
||||
if (normalizedSelected.has(optionKey)) {
|
||||
updateValues(selectedValues.filter((value) => normalizeOptionKey(value) !== optionKey));
|
||||
return;
|
||||
}
|
||||
|
||||
updateValues([...selectedValues, cleanOption]);
|
||||
};
|
||||
|
||||
const removeOption = (option: string) => {
|
||||
const optionKey = normalizeOptionKey(option);
|
||||
updateValues(selectedValues.filter((value) => normalizeOptionKey(value) !== optionKey));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"min-h-10 w-full justify-between rounded-md border-border bg-background px-3 font-normal hover:bg-background",
|
||||
!selectedSummary && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">{selectedSummary || placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList className="overscroll-contain" onWheel={handleDropdownWheel}>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{cleanOptions.map((option) => {
|
||||
const selected = normalizedSelected.has(normalizeOptionKey(option));
|
||||
|
||||
return (
|
||||
<CommandItem key={option} value={option} onSelect={() => toggleOption(option)}>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selected ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span className="truncate">{option}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedValues.map((value) => (
|
||||
<Badge key={value} variant="secondary" className="gap-1 rounded-full px-2 py-0.5">
|
||||
<span className="max-w-[190px] truncate">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(value)}
|
||||
className="rounded-full text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Quitar ${value}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useMemo, useState, type WheelEvent } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.scrollTop += event.deltaY;
|
||||
}
|
||||
|
||||
interface SearchableSelectProps {
|
||||
value: string;
|
||||
options: string[];
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
clearLabel?: string;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
placeholder = "Seleccionar…",
|
||||
searchPlaceholder = "Buscar…",
|
||||
emptyText = "No hay resultados.",
|
||||
disabled = false,
|
||||
clearLabel,
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedLabel = useMemo(
|
||||
() => options.find((option) => option === value) ?? value,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-10 w-full justify-between rounded-md border-border bg-background px-3 font-normal hover:bg-background",
|
||||
!selectedLabel && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{selectedLabel || placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[min(92vw,560px)] min-w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList className="overscroll-contain" onWheel={handleDropdownWheel}>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{clearLabel && (
|
||||
<>
|
||||
<CommandItem
|
||||
key="__clear__"
|
||||
value={`__clear_${clearLabel}`}
|
||||
onSelect={() => {
|
||||
onValueChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
|
||||
<span className="whitespace-normal break-words font-medium">{clearLabel}</span>
|
||||
</CommandItem>
|
||||
{options.length > 0 && <CommandSeparator className="my-1" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => {
|
||||
onValueChange(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span className="whitespace-normal break-words leading-snug">{option}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user