feat(cartao): adicionar cards com status e playground tipo trello

This commit is contained in:
Luiz Silva 2026-01-02 21:48:24 -03:00
parent ab15c51a1b
commit 5bb6732b81
8 changed files with 438 additions and 2 deletions

View file

@ -0,0 +1,148 @@
<template>
<v-card
class="eli-cartao"
:variant="variant"
:class="classeStatus"
v-bind="$attrs"
>
<v-card-title class="eli-cartao__titulo">
<div class="eli-cartao__titulo-texto">
<slot name="titulo">
{{ titulo }}
</slot>
</div>
<!-- Indicador de status (badge) padronizado pelo design system -->
<div class="eli-cartao__status">
<EliBadge
:badge="rotuloStatus"
radius="pill"
:color="corStatus"
>
<span />
</EliBadge>
</div>
</v-card-title>
<v-card-text class="eli-cartao__conteudo">
<slot />
</v-card-text>
<v-card-actions v-if="$slots.acoes" class="eli-cartao__acoes">
<slot name="acoes" />
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import type { CartaoStatus } from "../../tipos";
import { EliBadge } from "../indicador";
type CartaoVariante = "outlined" | "flat" | "elevated" | "tonal";
/**
* EliCartao
*
* Um cartão de domínio para listas/pipelines (ex.: oportunidades/propostas) com:
* - título
* - status padronizado (novo/rascunho/vendido/cancelado)
* - slot padrão para conteúdo
* - slot opcional para ações
*/
export default defineComponent({
name: "EliCartao",
components: { EliBadge },
inheritAttrs: false,
props: {
/** Título de fallback caso o slot `titulo` não seja usado. */
titulo: {
type: String,
default: "",
},
/**
* Status semântico do cartão.
* Usado para cor/label e para permitir filtros por status.
*/
status: {
type: String as PropType<CartaoStatus>,
required: true,
},
/** Variante visual do v-card (Vuetify). */
variant: {
type: String as PropType<CartaoVariante>,
default: "outlined",
},
},
emits: {
/** Emit opcional para padronizar clique no cartão. */
clicar: (_status: CartaoStatus) => true,
},
setup(props, { emit }) {
const rotuloStatus = computed(() => {
// Mantém label em PT-BR (não abreviar)
return props.status;
});
const corStatus = computed(() => {
// Cor neutra por padrão e semântica por status
switch (props.status) {
case "novo":
return "primary";
case "rascunho":
return "secondary";
case "vendido":
return "success";
case "cancelado":
return "error";
}
});
const classeStatus = computed(() => `eli-cartao--${props.status}`);
function onClick() {
emit("clicar", props.status);
}
return {
rotuloStatus,
corStatus,
classeStatus,
onClick,
};
},
});
</script>
<style scoped>
.eli-cartao {
border-radius: 12px;
}
.eli-cartao__titulo {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.eli-cartao__titulo-texto {
min-width: 0;
}
.eli-cartao__conteudo {
padding-top: 8px;
}
.eli-cartao__acoes {
padding-top: 0;
}
/* Hooks visuais por status (sem forçar cor, deixa o tema do Vuetify fazer o trabalho) */
.eli-cartao--cancelado {
opacity: 0.85;
}
</style>

View file

@ -0,0 +1,71 @@
# EliCartao
O `EliCartao` é um componente de cartão focado em **listas/pipelines** (ex.: oportunidades e propostas comerciais), com um **status semântico** padronizado.
Ele encapsula o `v-card` (Vuetify) e adiciona:
- Área de **título**
- **Badge de status** padronizado (`novo`, `rascunho`, `vendido`, `cancelado`)
- Slot de **conteúdo**
- Slot opcional de **ações**
## API
### Props
| Prop | Tipo | Padrão | Descrição |
|----------|--------------------------------------------------|--------------|-----------|
| `titulo` | `string` | `""` | Título de fallback (se o slot `titulo` não for usado). |
| `status` | `"novo" \| "rascunho" \| "vendido" \| "cancelado"` | *(obrigatório)* | Status semântico do cartão. Controla label/cor do badge e classes. |
| `variant`| `"outlined" \| "flat" \| "elevated" \| "tonal"` | `"outlined"` | Variante do `v-card` (Vuetify). |
### Emits
| Evento | Payload | Quando dispara |
|----------|-------------------------|----------------|
| `clicar` | `(status: CartaoStatus)`| Quando o cartão for clicado (opcional; útil para padronizar navegação). |
> Observação: o componente deixa `v-bind="$attrs"` para você passar `class`, `style`, `to`, `href`, etc.
## Slots
| Slot | Objetivo |
|----------|----------|
| `titulo` | Conteúdo customizado do título (override do `titulo`). |
| `default`| Corpo do cartão. |
| `acoes` | Rodapé de ações (botões/links). |
## Exemplos
### 1) Cartão simples
```vue
<EliCartao titulo="Proposta #1024" status="novo">
<div><strong>Cliente:</strong> ACME Ltda</div>
<div><strong>Valor:</strong> R$ 12.500,00</div>
</EliCartao>
```
### 2) Título via slot + ações
```vue
<EliCartao status="vendido">
<template #titulo>
<span>Oportunidade #204</span>
</template>
<div>Cliente: Empresa X</div>
<template #acoes>
<EliBotao variant="text">Abrir</EliBotao>
<EliBotao variant="text" color="secondary">Editar</EliBotao>
</template>
</EliCartao>
```
## Casos de borda / comportamento esperado
- `status` é obrigatório e sempre deve ser um valor suportado.
- Status `cancelado` aplica leve redução de opacidade via classe CSS.
- A renderização de ações só ocorre se o slot `acoes` existir.

View file

@ -0,0 +1,2 @@
export { default as EliCartao } from "./EliCartao.vue";

View file

@ -3,11 +3,13 @@ import { EliOlaMundo } from "./componentes/ola_mundo";
import { EliBotao } from "./componentes/botao"; import { EliBotao } from "./componentes/botao";
import { EliBadge } from "./componentes/indicador"; import { EliBadge } from "./componentes/indicador";
import { EliInput } from "./componentes/campo"; import { EliInput } from "./componentes/campo";
import { EliCartao } from "./componentes/cartao";
export { EliOlaMundo }; export { EliOlaMundo };
export { EliBotao }; export { EliBotao };
export { EliBadge }; export { EliBadge };
export { EliInput }; export { EliInput };
export { EliCartao };
const EliVue: Plugin = { const EliVue: Plugin = {
install(app: App) { install(app: App) {
@ -15,6 +17,7 @@ const EliVue: Plugin = {
app.component("EliBotao", EliBotao); app.component("EliBotao", EliBotao);
app.component("EliBadge", EliBadge); app.component("EliBadge", EliBadge);
app.component("EliInput", EliInput); app.component("EliInput", EliInput);
app.component("EliCartao", EliCartao);
}, },
}; };

View file

@ -5,6 +5,7 @@
<v-tabs v-model="aba" color="primary" density="comfortable"> <v-tabs v-model="aba" color="primary" density="comfortable">
<v-tab value="botao">Botão</v-tab> <v-tab value="botao">Botão</v-tab>
<v-tab value="indicador">Indicador</v-tab> <v-tab value="indicador">Indicador</v-tab>
<v-tab value="cartao">Cartão</v-tab>
<v-tab value="campo">Campo</v-tab> <v-tab value="campo">Campo</v-tab>
<v-tab value="ola_mundo">Demo</v-tab> <v-tab value="ola_mundo">Demo</v-tab>
</v-tabs> </v-tabs>
@ -13,6 +14,7 @@
<BotaoPlayground v-if="aba === 'botao'" /> <BotaoPlayground v-if="aba === 'botao'" />
<IndicadorPlayground v-else-if="aba === 'indicador'" /> <IndicadorPlayground v-else-if="aba === 'indicador'" />
<CartaoPlayground v-else-if="aba === 'cartao'" />
<CampoPlayground v-else-if="aba === 'campo'" /> <CampoPlayground v-else-if="aba === 'campo'" />
<OlaMundoPlayground v-else /> <OlaMundoPlayground v-else />
</v-container> </v-container>
@ -22,6 +24,7 @@
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import BotaoPlayground from "./botao.playground.vue"; import BotaoPlayground from "./botao.playground.vue";
import IndicadorPlayground from "./indicador.playground.vue"; import IndicadorPlayground from "./indicador.playground.vue";
import CartaoPlayground from "./cartao.playground.vue";
import CampoPlayground from "./campo.playground.vue"; import CampoPlayground from "./campo.playground.vue";
import OlaMundoPlayground from "./ola_mundo.playground.vue"; import OlaMundoPlayground from "./ola_mundo.playground.vue";
@ -30,12 +33,13 @@ export default defineComponent({
components: { components: {
BotaoPlayground, BotaoPlayground,
IndicadorPlayground, IndicadorPlayground,
CartaoPlayground,
CampoPlayground, CampoPlayground,
OlaMundoPlayground, OlaMundoPlayground,
}, },
data() { data() {
return { return {
aba: "botao" as "botao" | "indicador" | "campo" | "ola_mundo", aba: "botao" as "botao" | "indicador" | "cartao" | "campo" | "ola_mundo",
}; };
} }
}) })

View file

@ -0,0 +1,202 @@
<template>
<section class="stack">
<h2>EliCartao (pipeline)</h2>
<div class="toolbar">
<EliInput
v-model="filtro"
label="Buscar"
placeholder="Cliente, título, valor..."
density="compact"
/>
<EliBotao @click="criar">Novo card</EliBotao>
</div>
<div class="colunas">
<section v-for="coluna in colunas" :key="coluna.status" class="coluna">
<header class="coluna-header">
<strong class="text-subtitle-1">{{ coluna.titulo }}</strong>
<EliBadge :badge="coluna.itens.length" radius="pill" />
</header>
<div class="cards">
<EliCartao
v-for="item in itensFiltrados(coluna.itens)"
:key="item.id"
:titulo="item.titulo"
:status="coluna.status"
>
<div class="linha"><strong>Cliente:</strong> {{ item.cliente }}</div>
<div class="linha"><strong>Valor:</strong> {{ item.valor }}</div>
<div class="linha"><strong>Vencimento:</strong> {{ item.vencimento }}</div>
<template #acoes>
<EliBotao variant="text" @click="abrir(item)">Abrir</EliBotao>
<EliBotao variant="text" color="secondary" @click="editar(item)">
Editar
</EliBotao>
</template>
</EliCartao>
</div>
</section>
</div>
</section>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import { EliBadge } from "@/componentes/indicador";
import { EliBotao } from "@/componentes/botao";
import { EliInput } from "@/componentes/campo";
import { EliCartao } from "@/componentes/cartao";
import type { CartaoStatus } from "@/tipos";
type Card = {
id: string;
titulo: string;
cliente: string;
valor: string;
vencimento: string;
};
type Coluna = {
status: CartaoStatus;
titulo: string;
itens: Card[];
};
export default defineComponent({
name: "CartaoPlayground",
components: { EliBadge, EliBotao, EliInput, EliCartao },
setup() {
const filtro = ref("");
const colunas = ref<Coluna[]>([
{
status: "novo",
titulo: "Novo",
itens: [
{
id: "1",
titulo: "Proposta #1024",
cliente: "ACME Ltda",
valor: "R$ 12.500,00",
vencimento: "10/01/2026",
},
],
},
{
status: "rascunho",
titulo: "Rascunho",
itens: [
{
id: "2",
titulo: "Oportunidade #204",
cliente: "Empresa X",
valor: "R$ 8.000,00",
vencimento: "15/01/2026",
},
],
},
{
status: "vendido",
titulo: "Vendido",
itens: [],
},
{
status: "cancelado",
titulo: "Cancelado",
itens: [
{
id: "3",
titulo: "Proposta #999",
cliente: "Cliente Y",
valor: "R$ 3.200,00",
vencimento: "02/01/2026",
},
],
},
]);
function itensFiltrados(itens: Card[]) {
const q = filtro.value.trim().toLowerCase();
if (!q) return itens;
return itens.filter((i) =>
`${i.titulo} ${i.cliente} ${i.valor}`.toLowerCase().includes(q)
);
}
function criar() {
// Exemplo: criar card no status "novo"
colunas.value[0]?.itens.unshift({
id: String(Date.now()),
titulo: "Nova oportunidade",
cliente: "(definir)",
valor: "R$ 0,00",
vencimento: "--/--/----",
});
}
function abrir(_item: Card) {
// Navegar para detalhes
}
function editar(_item: Card) {
// Abrir modal/rota de edição
}
return { filtro, colunas, itensFiltrados, criar, abrir, editar };
},
});
</script>
<style scoped>
.stack {
display: grid;
gap: 16px;
}
.toolbar {
display: grid;
gap: 12px;
grid-template-columns: minmax(260px, 420px) auto;
align-items: center;
}
.colunas {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.coluna {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 12px;
padding: 12px;
background: rgba(0, 0, 0, 0.02);
}
.coluna-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.cards {
display: grid;
gap: 12px;
}
.linha {
margin-bottom: 4px;
}
@media (max-width: 720px) {
.toolbar {
grid-template-columns: 1fr;
}
}
</style>

6
src/tipos/cartao.ts Normal file
View file

@ -0,0 +1,6 @@
/**
* Tipos do componente EliCartao.
*/
export type CartaoStatus = "novo" | "rascunho" | "vendido" | "cancelado";

View file

@ -1,4 +1,4 @@
export * from "./botao"; export * from "./botao";
export * from "./cartao";
export * from "./campo"; export * from "./campo";
export * from "./indicador"; export * from "./indicador";