Sprint 4 — Etapa 7B completada.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
158
sprints/sprint-4/ETAPA_7B_PROMPT.md
Normal file
158
sprints/sprint-4/ETAPA_7B_PROMPT.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Sprint 4 — Etapa 7B: Tres fixes post-validación visual
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
|
||||||
|
Etapa 7 completada con OK formal. Durante la validación visual del director se detectaron tres issues que entran en esta etapa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 1 — Catálogo con contexto de proceso (Opción A aprobada)
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
|
||||||
|
Cuando el usuario navega desde el ResourcesPanel del workspace al catálogo (`/recursos`), no hay forma de volver al proceso de origen ni de indicar que un recurso está disponible para usar en ese proceso.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
|
||||||
|
**Paso 1: ResourcesPanel — pasar contexto en la URL**
|
||||||
|
|
||||||
|
En `src/features/workspace/ResourcesPanel.tsx`, el link "Ver catálogo completo" debe incluir el `processId` del proceso actual como query param:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Antes:
|
||||||
|
navigate({ to: '/recursos' })
|
||||||
|
|
||||||
|
// Después:
|
||||||
|
navigate({ to: '/recursos', search: { fromProcess: currentProcess.id } })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paso 2: ResourceCatalogPage — detectar contexto y mostrar UI de retorno**
|
||||||
|
|
||||||
|
En `src/features/resources/ResourceCatalogPage.tsx`:
|
||||||
|
|
||||||
|
Leer el query param `fromProcess` (puede ser undefined si el usuario navegó directamente).
|
||||||
|
|
||||||
|
Si `fromProcess` está presente:
|
||||||
|
|
||||||
|
1. **Banner contextual** en la parte superior de la página (antes de la barra de herramientas), con fondo suave naranja InQ:
|
||||||
|
```
|
||||||
|
[←] Estás viendo el catálogo desde el proceso "[nombre del proceso]"
|
||||||
|
Seleccioná un recurso y usalo en una actividad al volver al workspace.
|
||||||
|
[← Volver al proceso]
|
||||||
|
```
|
||||||
|
- El nombre del proceso se resuelve buscando `fromProcess` en el store o con una query puntual a Supabase
|
||||||
|
- El botón "← Volver al proceso" navega de vuelta a `/workspace/:processId`
|
||||||
|
|
||||||
|
2. **Botón "← Usar en proceso" en cada fila de la tabla** (columna Acciones, junto a editar/eliminar):
|
||||||
|
- Solo visible cuando `fromProcess` está presente
|
||||||
|
- Al hacer click: navega a `/workspace/:processId`
|
||||||
|
- No hace ninguna asignación automática — la asignación sigue siendo en ActivityPanel
|
||||||
|
- Tooltip: "Volver al workspace para asignar este recurso a una actividad"
|
||||||
|
|
||||||
|
**Colores del banner:**
|
||||||
|
- Fondo: `#FEF3E7` (naranja InQ muy suave)
|
||||||
|
- Borde izquierdo: `#F59845` (naranja InQ)
|
||||||
|
- Texto secundario: `var(--color-text-secondary)`
|
||||||
|
- Botón "Volver": estilo secundario (borde, sin relleno)
|
||||||
|
|
||||||
|
**Si `fromProcess` no está presente** (usuario llegó al catálogo directamente): sin banner, sin botón extra. La página funciona exactamente igual que en Etapa 7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 2 — Redondeo del costo por hora para valores pequeños
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
|
||||||
|
Un recurso con costo `$0.037/h` se muestra como `$0.04` — pérdida de información para recursos de bajo costo unitario (licencias de software, servicios cloud por hora, etc.).
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
|
||||||
|
Crear o actualizar la función de formateo de costo por hora para que use precisión dinámica. Ubicar en `src/lib/format.ts` (o donde esté la función `formatCurrency` / `formatCostPerHour`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function formatCostPerHour(value: number, currency = 'USD'): string {
|
||||||
|
if (value === 0) return '$0'
|
||||||
|
|
||||||
|
// Determinar decimales según magnitud
|
||||||
|
let decimals: number
|
||||||
|
if (value >= 100) decimals = 0 // $150/h → $150
|
||||||
|
else if (value >= 10) decimals = 1 // $12.5/h → $12.5
|
||||||
|
else if (value >= 1) decimals = 2 // $1.23/h → $1.23
|
||||||
|
else if (value >= 0.1) decimals = 3 // $0.123/h → $0.123
|
||||||
|
else decimals = 4 // $0.0123/h → $0.0123
|
||||||
|
|
||||||
|
return `$${value.toFixed(decimals)}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aplicar esta función en todos los lugares donde se muestra el costo por hora de un recurso:
|
||||||
|
- Tabla del catálogo `/recursos`
|
||||||
|
- ResourcesPanel (lista de recursos usados en el proceso)
|
||||||
|
- ActivityPanel (selector de recursos y fila de recurso asignado)
|
||||||
|
- Cualquier otro lugar donde aparezca `costPerHour`
|
||||||
|
|
||||||
|
**Verificación:** ingresar un recurso con costo `0.037` y confirmar que se muestra `$0.037` (no `$0.04`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 3 — Google OAuth: forzar selector de cuenta
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
|
||||||
|
Al hacer click en "Iniciar sesión con Google", el navegador usa directamente la sesión anterior sin mostrar el selector de cuentas de Google. Impide cambiar de cuenta o seleccionar entre múltiples cuentas del navegador.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
|
||||||
|
En `src/auth/SupabaseAuthService.ts`, en el método `signInWithGoogle()`, agregar `prompt: 'select_account'` en los `queryParams`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async signInWithGoogle(): Promise<void> {
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: 'google',
|
||||||
|
options: {
|
||||||
|
redirectTo: `${window.location.origin}/auth/callback`,
|
||||||
|
queryParams: {
|
||||||
|
prompt: 'select_account', // 🆕 fuerza el selector de cuentas de Google
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`prompt: 'select_account'` es un parámetro estándar de OAuth 2.0 / OpenID Connect que instruye a Google a siempre mostrar el selector de cuentas, incluso si hay una sesión activa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qué NO entra en esta etapa
|
||||||
|
|
||||||
|
- Asignación automática de recursos desde el catálogo (eso sería Opción B — diferida)
|
||||||
|
- Cambios en PDF, simulación o workspace salvo los indicados
|
||||||
|
- Ningún cambio de schema en Supabase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Orden de ejecución
|
||||||
|
|
||||||
|
1. Fix 3 — `SupabaseAuthService.signInWithGoogle()` (1 línea, más fácil de verificar primero)
|
||||||
|
2. Fix 2 — función `formatCostPerHour` con precisión dinámica + aplicar en todos los contextos
|
||||||
|
3. Fix 1 — query param en ResourcesPanel + banner + botón en ResourceCatalogPage
|
||||||
|
4. `npm run build` — debe pasar sin errores TypeScript
|
||||||
|
5. `npm run test` — debe mantener 521+ tests verdes
|
||||||
|
6. Commit: `fix(sprint-4/etapa-7b): selector cuenta Google + redondeo costo/hora + contexto catálogo`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de validación
|
||||||
|
|
||||||
|
- [ ] Login muestra el selector de cuentas de Google (no auto-login)
|
||||||
|
- [ ] Recurso con costo `0.037` se muestra como `$0.037`, no `$0.04`
|
||||||
|
- [ ] Recurso con costo `45` se muestra como `$45`, no `$45.00` (sin decimales innecesarios)
|
||||||
|
- [ ] Navegar desde ResourcesPanel → catálogo muestra el banner con nombre del proceso
|
||||||
|
- [ ] Botón "← Usar en proceso" aparece en cada fila cuando hay contexto de proceso
|
||||||
|
- [ ] Click en "← Usar en proceso" navega a `/workspace/:processId`
|
||||||
|
- [ ] Navegar a `/recursos` directamente (sin `fromProcess`) no muestra banner ni botón extra
|
||||||
|
- [ ] Build limpio sin errores TypeScript
|
||||||
|
- [ ] 521+ tests verdes
|
||||||
|
- [ ] **Validación visual del director** antes del OK formal
|
||||||
@@ -5,7 +5,12 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
async signInWithGoogle(): Promise<void> {
|
async signInWithGoogle(): Promise<void> {
|
||||||
const { error } = await supabase.auth.signInWithOAuth({
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
options: { redirectTo: window.location.origin },
|
options: {
|
||||||
|
redirectTo: window.location.origin,
|
||||||
|
queryParams: {
|
||||||
|
prompt: 'select_account', // fuerza el selector de cuentas de Google, incluso con sesión activa
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Users2, Search, Plus, Pencil, Trash2 } from 'lucide-react'
|
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||||
|
import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft } from 'lucide-react'
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { supabaseResourceRepo, type ResourceWithCreator } from '@/persistence/supabase/resource-repo'
|
import { supabaseResourceRepo, type ResourceWithCreator } from '@/persistence/supabase/resource-repo'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||||
|
import { formatCostPerHour } from '@/lib/format'
|
||||||
import { RESOURCE_TYPE_LABELS, RESOURCE_TYPE_STYLES, ResourceTypeAvatar } from './resource-display'
|
import { RESOURCE_TYPE_LABELS, RESOURCE_TYPE_STYLES, ResourceTypeAvatar } from './resource-display'
|
||||||
import { ResourceFormSheet } from './ResourceFormSheet'
|
import { ResourceFormSheet } from './ResourceFormSheet'
|
||||||
import type { Resource, ResourceType } from '@/domain/types'
|
import type { Resource, ResourceType } from '@/domain/types'
|
||||||
|
|
||||||
export function ResourceCatalogPage() {
|
export function ResourceCatalogPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const search_ = useSearch({ from: '/recursos' })
|
||||||
|
const fromProcess = (search_ as { fromProcess?: string }).fromProcess
|
||||||
|
const [fromProcessName, setFromProcessName] = useState<string | null>(null)
|
||||||
const [resources, setResources] = useState<ResourceWithCreator[]>([])
|
const [resources, setResources] = useState<ResourceWithCreator[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -24,6 +31,15 @@ export function ResourceCatalogPage() {
|
|||||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fromProcess) return
|
||||||
|
supabaseProcessRepo.getById(fromProcess).then((p) => setFromProcessName(p?.name ?? null))
|
||||||
|
}, [fromProcess])
|
||||||
|
|
||||||
|
function backToProcess() {
|
||||||
|
if (fromProcess) navigate({ to: '/workspace/$processId', params: { processId: fromProcess } })
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -92,6 +108,31 @@ export function ResourceCatalogPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fromProcess && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-3 mb-4 px-4 py-3 rounded-lg"
|
||||||
|
style={{ background: '#FEF3E7', borderLeft: '3px solid #F59845' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 -ml-1" onClick={backToProcess}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-medium">
|
||||||
|
Estás viendo el catálogo desde el proceso {fromProcessName ? `"${fromProcessName}"` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-muted-foreground">
|
||||||
|
Seleccioná un recurso y usalo en una actividad al volver al workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5 shrink-0" onClick={backToProcess}>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Volver al proceso
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{deleteError && (
|
{deleteError && (
|
||||||
<div className="mb-4 px-3 py-2 rounded-lg text-[12px] border border-destructive/30 bg-destructive/10 text-destructive">
|
<div className="mb-4 px-3 py-2 rounded-lg text-[12px] border border-destructive/30 bg-destructive/10 text-destructive">
|
||||||
{deleteError}
|
{deleteError}
|
||||||
@@ -164,7 +205,7 @@ export function ResourceCatalogPage() {
|
|||||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
{RESOURCE_TYPE_LABELS[resource.type]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[13px]">{formatCurrency(resource.costPerHour)}</TableCell>
|
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
|
||||||
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</TableCell>
|
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{confirmingDeleteId === resource.id ? (
|
{confirmingDeleteId === resource.id ? (
|
||||||
@@ -179,6 +220,18 @@ export function ResourceCatalogPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{fromProcess && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={backToProcess}>
|
||||||
|
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-48 text-xs">
|
||||||
|
Volver al workspace para asignar este recurso a una actividad
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(resource)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(resource)}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { useProcessStore } from '@/store/process-store'
|
import { useProcessStore } from '@/store/process-store'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCurrency, formatCostPerHour } from '@/lib/format'
|
||||||
import type { Activity } from '@/domain/types'
|
import type { Activity } from '@/domain/types'
|
||||||
|
|
||||||
interface ActivityPanelProps {
|
interface ActivityPanelProps {
|
||||||
@@ -254,7 +254,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
|
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
|
||||||
<p className="text-xs text-slate-400 font-mono">{formatCurrency(resource.costPerHour)}/h</p>
|
<p className="text-xs text-slate-400 font-mono">{formatCostPerHour(resource.costPerHour)}/h</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -318,7 +318,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableResources.map((r) => (
|
{availableResources.map((r) => (
|
||||||
<SelectItem key={r.id} value={r.id} className="text-xs">
|
<SelectItem key={r.id} value={r.id} className="text-xs">
|
||||||
{r.name} — {formatCurrency(r.costPerHour)}/h
|
{r.name} — {formatCostPerHour(r.costPerHour)}/h
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { useProcessStore } from '@/store/process-store'
|
import { useProcessStore } from '@/store/process-store'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { formatCurrency } from '@/lib/format'
|
import { formatCostPerHour } from '@/lib/format'
|
||||||
import { RESOURCE_TYPE_LABELS, ResourceTypeAvatar } from '@/features/resources/resource-display'
|
import { RESOURCE_TYPE_LABELS, ResourceTypeAvatar } from '@/features/resources/resource-display'
|
||||||
import { ResourceFormSheet } from '@/features/resources/ResourceFormSheet'
|
import { ResourceFormSheet } from '@/features/resources/ResourceFormSheet'
|
||||||
import type { Resource } from '@/domain/types'
|
import type { Resource } from '@/domain/types'
|
||||||
|
|
||||||
export function ResourcesPanel() {
|
export function ResourcesPanel() {
|
||||||
const { activities, resources, addResource } = useProcessStore()
|
const { currentProcess, activities, resources, addResource } = useProcessStore()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
@@ -65,7 +65,7 @@ export function ResourcesPanel() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
|
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
|
||||||
<p className="text-xs text-slate-500 font-mono shrink-0">{formatCurrency(resource.costPerHour)}/h</p>
|
<p className="text-xs text-slate-500 font-mono shrink-0">{formatCostPerHour(resource.costPerHour)}/h</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
{RESOURCE_TYPE_LABELS[resource.type]} · {usageCount(resource.id)} actividad{usageCount(resource.id) !== 1 ? 'es' : ''}
|
{RESOURCE_TYPE_LABELS[resource.type]} · {usageCount(resource.id)} actividad{usageCount(resource.id) !== 1 ? 'es' : ''}
|
||||||
@@ -84,7 +84,10 @@ export function ResourcesPanel() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full h-8 text-xs gap-1.5"
|
className="w-full h-8 text-xs gap-1.5"
|
||||||
onClick={() => navigate({ to: '/recursos' })}
|
onClick={() => navigate({
|
||||||
|
to: '/recursos',
|
||||||
|
search: currentProcess ? { fromProcess: currentProcess.id } : undefined,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
Ver catálogo completo
|
Ver catálogo completo
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ export function formatCurrency(amount: number, currency = 'USD'): string {
|
|||||||
}).format(amount)
|
}).format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Precisión dinámica para costo/hora de recursos: evita que valores chicos
|
||||||
|
// (ej. licencias cloud a $0.037/h) se redondeen a $0.04 perdiendo la magnitud real.
|
||||||
|
export function formatCostPerHour(value: number, currency = 'USD'): string {
|
||||||
|
const symbol = currency === 'USD' ? '$' : currency
|
||||||
|
if (value === 0) return `${symbol}0`
|
||||||
|
|
||||||
|
let decimals: number
|
||||||
|
if (value >= 100) decimals = 0
|
||||||
|
else if (value >= 10) decimals = 1
|
||||||
|
else if (value >= 1) decimals = 2
|
||||||
|
else if (value >= 0.1) decimals = 3
|
||||||
|
else decimals = 4
|
||||||
|
|
||||||
|
// parseFloat recorta ceros sobrantes: toFixed(4) de 0.037 da "0.0370", queremos "0.037"
|
||||||
|
return `${symbol}${parseFloat(value.toFixed(decimals))}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatNumber(n: number, decimals = 1): string {
|
export function formatNumber(n: number, decimals = 1): string {
|
||||||
return new Intl.NumberFormat('es-PY', {
|
return new Intl.NumberFormat('es-PY', {
|
||||||
minimumFractionDigits: decimals,
|
minimumFractionDigits: decimals,
|
||||||
|
|||||||
Reference in New Issue
Block a user