activos-fijos #18

Merged
edgar.mendez merged 2 commits from activos-fijos into qa 2026-03-10 23:16:42 +00:00
30 changed files with 2686 additions and 10 deletions

34
colors.css Normal file
View File

@ -0,0 +1,34 @@
@theme {
--color-page: #fff;
--color-page-t: #000;
--color-page-d: #292524;
--color-page-dt: #fff;
--color-primary: #374151;
--color-primary-t: #fff;
--color-primary-d: #1c1917;
--color-primary-dt: #fff;
--color-secondary: #3b82f6;
--color-secondary-t: #fff;
--color-secondary-d: #312e81;
--color-secondary-dt: #fff;
--color-primary-info: #06b6d4;
--color-primary-info-t: #fff;
--color-primary-info-d: #06b6d4;
--color-primary-info-dt: #fff;
--color-secondary-info: #06b6d4;
--color-secondary-info-t: #fff;
--color-secondary-info-d: #06b6d4;
--color-secondary-info-dt: #fff;
--color-success: #22c55e;
--color-success-t: #fff;
--color-success-d: #22c55e;
--color-success-dt: #fff;
--color-danger: #ef4444;
--color-danger-t: #fff;
--color-danger-d: #ef4444;
--color-danger-dt: #fecaca;
--color-warning: #eab308;
--color-warning-t: #fff;
--color-warning-d: #eab308;
--color-warning-dt: #fff;
}

10
components.d.ts vendored
View File

@ -12,14 +12,10 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AppConfig: typeof import('./src/components/layout/AppConfig.vue')['default']
AppTopbar: typeof import('./src/components/Holos/AppTopbar.vue')['default']
Avatar: typeof import('primevue/avatar')['default']
Badge: typeof import('primevue/badge')['default']
Breadcrumb: typeof import('primevue/breadcrumb')['default']
Button: typeof import('primevue/button')['default']
Card: typeof import('primevue/card')['default']
Checkbox: typeof import('primevue/checkbox')['default']
Chip: typeof import('primevue/chip')['default']
Column: typeof import('primevue/column')['default']
ConfirmDialog: typeof import('primevue/confirmdialog')['default']
DataTable: typeof import('primevue/datatable')['default']
@ -27,11 +23,8 @@ declare module 'vue' {
Dropdown: typeof import('primevue/dropdown')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IconField: typeof import('primevue/iconfield')['default']
InputGroup: typeof import('primevue/inputgroup')['default']
InputGroupAddon: typeof import('primevue/inputgroupaddon')['default']
InputIcon: typeof import('primevue/inputicon')['default']
InputNumber: typeof import('primevue/inputnumber')['default']
InputSwitch: typeof import('primevue/inputswitch')['default']
InputText: typeof import('primevue/inputtext')['default']
KpiCard: typeof import('./src/components/shared/KpiCard.vue')['default']
Menu: typeof import('primevue/menu')['default']
@ -40,15 +33,12 @@ declare module 'vue' {
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default']
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
Tag: typeof import('primevue/tag')['default']
Textarea: typeof import('primevue/textarea')['default']
Toast: typeof import('primevue/toast')['default']
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
}
export interface GlobalDirectives {
StyleClass: typeof import('primevue/styleclass')['default']
Tooltip: typeof import('primevue/tooltip')['default']
}
}

View File

@ -71,6 +71,19 @@ const menuItems = ref<MenuItem[]>([
icon: 'pi pi-cog',
to: '/stores'
},
{
label: 'Activos Fijos',
icon: 'pi pi-building',
items: [
{ label: 'Registro de Activos', icon: 'pi pi-building', to: '/fixed-assets' },
{ label: 'Asignacion a Empleado', icon: 'pi pi-send', to: '/fixed-assets/assignments' },
{ label: 'Estructura de Activos', icon: 'pi pi-sitemap', to: '/fixed-assets/structures' },
// { label: 'Marcas', icon: 'pi pi-building', to: '/fixed-assets/brands' },
// { label: 'Modelos', icon: 'pi pi-building', to: '/fixed-assets/models' },
// { label: 'Estados', icon: 'pi pi-building', to: '/fixed-assets/states' },
// { label: 'Ubicaciones', icon: 'pi pi-building', to: '/fixed-assets/locations' },
],
},
{
label: 'Configuración',
icon: 'pi pi-cog',

View File

@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import Button from 'primevue/button';
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import Select from 'primevue/select';
import Paginator from 'primevue/paginator';
const router = useRouter();
interface FixedAsset {
id: string;
name: string;
serial: string;
category: string;
location: string;
status: 'ASIGNADO' | 'MANTENIMIENTO' | 'DISPONIBLE';
value: number;
}
const searchQuery = ref('');
const selectedCategory = ref('all');
const selectedStatus = ref('all');
const rowsPerPage = ref(4);
const first = ref(0);
const categoryOptions = [
{ label: 'Todas las Categorías', value: 'all' },
{ label: 'Cómputo', value: 'Cómputo' },
{ label: 'Maquinaria', value: 'Maquinaria' },
{ label: 'Mobiliario', value: 'Mobiliario' },
{ label: 'TI Infra', value: 'TI Infra' }
];
const statusOptions = [
{ label: 'Todos los Estatus', value: 'all' },
{ label: 'Asignado', value: 'ASIGNADO' },
{ label: 'Mantenimiento', value: 'MANTENIMIENTO' },
{ label: 'Disponible', value: 'DISPONIBLE' }
];
const fixedAssets = ref<FixedAsset[]>([
{
id: 'ACT-0001',
name: 'MacBook Pro 14"',
serial: 'SN-928374',
category: 'Cómputo',
location: 'Oficina Central - Piso 2',
status: 'ASIGNADO',
value: 45000
},
{
id: 'ACT-0042',
name: 'Montacargas Hidráulico',
serial: 'MH-1029',
category: 'Maquinaria',
location: 'Almacén General - Pasillo A',
status: 'MANTENIMIENTO',
value: 185200
},
{
id: 'ACT-0056',
name: 'Escritorio Ergonómico',
serial: 'N/A',
category: 'Mobiliario',
location: 'Sucursal Norte',
status: 'DISPONIBLE',
value: 8400
},
{
id: 'ACT-0099',
name: 'Servidor Dell PowerEdge',
serial: 'SV-3301',
category: 'TI Infra',
location: 'Data Center',
status: 'ASIGNADO',
value: 120000
},
{
id: 'ACT-0103',
name: 'Impresora Industrial ZT610',
serial: 'ZT-7745',
category: 'Maquinaria',
location: 'Planta Producción',
status: 'DISPONIBLE',
value: 76250
},
{
id: 'ACT-0114',
name: 'Cámara de Seguridad PTZ',
serial: 'CAM-445',
category: 'TI Infra',
location: 'Oficina Central - Lobby',
status: 'ASIGNADO',
value: 12890
}
]);
const filteredAssets = computed(() => {
return fixedAssets.value.filter((asset) => {
const normalizedSearch = searchQuery.value.trim().toLowerCase();
const matchesSearch = !normalizedSearch
|| asset.id.toLowerCase().includes(normalizedSearch)
|| asset.name.toLowerCase().includes(normalizedSearch)
|| asset.serial.toLowerCase().includes(normalizedSearch);
const matchesCategory = selectedCategory.value === 'all' || asset.category === selectedCategory.value;
const matchesStatus = selectedStatus.value === 'all' || asset.status === selectedStatus.value;
return matchesSearch && matchesCategory && matchesStatus;
});
});
const paginatedAssets = computed(() => {
return filteredAssets.value.slice(first.value, first.value + rowsPerPage.value);
});
const totalRecords = computed(() => filteredAssets.value.length);
const onPageChange = (event: { first: number; rows: number }) => {
first.value = event.first;
rowsPerPage.value = event.rows;
};
const formatCurrency = (value: number) => {
return `$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const statusClasses: Record<FixedAsset['status'], string> = {
ASIGNADO: 'bg-blue-100 text-blue-700',
MANTENIMIENTO: 'bg-amber-100 text-amber-700',
DISPONIBLE: 'bg-emerald-100 text-emerald-700'
};
const goToStructures = () => {
router.push('/fixed-assets/structures');
};
const goToCreateAsset = () => {
router.push('/fixed-assets/create');
};
const goToAssignment = () => {
router.push('/fixed-assets/assignments');
};
</script>
<template>
<section class="space-y-6">
<!-- Header -->
<div class="flex flex-wrap justify-between gap-4 items-center">
<div class="flex min-w-72 flex-col gap-1">
<h1 class="text-surface-900 dark:text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">
Activos Fijos
</h1>
<p class="text-surface-500 dark:text-surface-400 text-base font-normal leading-normal">
Control detallado y trazabilidad de bienes corporativos
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button
label="Exportar"
icon="pi pi-file-export"
outlined
severity="secondary"
class="min-w-[200px]"
/>
<Button
label="Registrar Activo"
icon="pi pi-plus"
class="min-w-[200px]"
@click="goToCreateAsset"
/>
<Button
label="Estructuras"
icon="pi pi-sitemap"
severity="secondary"
outlined
class="min-w-40"
@click="goToStructures"
/>
<Button
label="Asignar Activo"
icon="pi pi-send"
severity="secondary"
outlined
class="min-w-44"
@click="goToAssignment"
/>
</div>
</div>
<!-- Table Card -->
<Card class="shadow-sm">
<template #content>
<div class="space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div class="flex w-full items-center gap-3 xl:max-w-xl">
<IconField iconPosition="left" class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="searchQuery"
class="w-full"
placeholder="Buscar por código, serie o descripción..."
/>
</IconField>
<Button
icon="pi pi-sliders-h"
outlined
severity="secondary"
aria-label="Filtros avanzados"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<Select
v-model="selectedCategory"
:options="categoryOptions"
optionLabel="label"
optionValue="value"
class="min-w-48"
/>
<Select
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="min-w-44"
/>
</div>
</div>
<div class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="min-w-full border-collapse">
<thead>
<tr class="bg-surface-50 text-left text-xs font-semibold uppercase tracking-wide text-surface-500 dark:bg-surface-800 dark:text-surface-300">
<th class="px-4 py-4">ID</th>
<th class="px-4 py-4">Descripción / Activo</th>
<th class="px-4 py-4">Categoría</th>
<th class="px-4 py-4">Ubicación</th>
<th class="px-4 py-4">Estatus</th>
<th class="px-4 py-4">Valor MXN</th>
<th class="px-4 py-4 text-right">Acciones</th>
</tr>
</thead>
<tbody>
<tr
v-for="asset in paginatedAssets"
:key="asset.id"
class="border-t border-surface-200 text-sm text-surface-700 dark:border-surface-700 dark:text-surface-200"
>
<td class="px-4 py-4 font-medium text-surface-500 dark:text-surface-400">
{{ asset.id }}
</td>
<td class="px-4 py-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-surface-100 dark:bg-surface-800">
<i class="pi pi-image text-surface-400"></i>
</div>
<div>
<p class="font-semibold text-surface-900 dark:text-surface-0">
{{ asset.name }}
</p>
<p class="text-xs text-surface-500 dark:text-surface-400">
Serie: {{ asset.serial }}
</p>
</div>
</div>
</td>
<td class="px-4 py-4">{{ asset.category }}</td>
<td class="px-4 py-4">{{ asset.location }}</td>
<td class="px-4 py-4">
<span
class="inline-flex rounded-full px-3 py-1 text-xs font-semibold"
:class="statusClasses[asset.status]"
>
{{ asset.status }}
</span>
</td>
<td class="px-4 py-4 text-lg font-semibold text-surface-900 dark:text-surface-0">
{{ formatCurrency(asset.value) }}
</td>
<td class="px-4 py-4">
<div class="flex items-center justify-end gap-1">
<Button icon="pi pi-pencil" text rounded size="small" />
<Button icon="pi pi-qrcode" text rounded size="small" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" />
</div>
</td>
</tr>
<tr v-if="paginatedAssets.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-surface-500 dark:text-surface-400">
No se encontraron activos con los filtros seleccionados.
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 pt-1 md:flex-row md:items-center md:justify-between">
<p class="text-sm text-surface-500 dark:text-surface-400">
Mostrando {{ paginatedAssets.length }} de {{ totalRecords }} activos
</p>
<Paginator
:first="first"
:rows="rowsPerPage"
:totalRecords="totalRecords"
:rowsPerPageOptions="[4, 8, 12]"
template="PrevPageLink PageLinks NextPageLink"
@page="onPageChange"
/>
</div>
</div>
</template>
</Card>
</section>
</template>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import type { FixedAssetFormData } from '../../types/fixedAsset';
interface Props {
form: FixedAssetFormData;
}
defineProps<Props>();
const locationOptions = [
{ label: 'Seleccione ubicacion de almacen', value: '' },
{ label: 'Almacen General', value: 'Almacen General' },
{ label: 'Sucursal Norte', value: 'Sucursal Norte' },
{ label: 'Oficina Central - Piso 2', value: 'Oficina Central - Piso 2' },
{ label: 'Data Center', value: 'Data Center' }
];
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-wallet text-primary"></i>
<span>Adquisicion y Ubicacion</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Fecha de Compra
</label>
<InputText
v-model="form.purchaseDate"
type="date"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Precio de Compra (MXN)
</label>
<InputNumber
v-model="form.purchasePrice"
mode="currency"
currency="MXN"
locale="es-MX"
class="w-full"
/>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Ubicacion Inicial
</label>
<Select
v-model="form.initialLocation"
:options="locationOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import type { FixedAssetFormData } from '../../types/fixedAsset';
interface Props {
form: FixedAssetFormData;
}
defineProps<Props>();
const statusOptions = [
{ label: 'Disponible', value: 'Disponible' },
{ label: 'Asignado', value: 'Asignado' },
{ label: 'Mantenimiento', value: 'Mantenimiento' }
];
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-user-plus text-primary"></i>
<span>Asignacion Inicial</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Estatus Inicial
</label>
<Select
v-model="form.initialStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Empleado Responsable
</label>
<InputText
v-model="form.responsibleEmployee"
class="w-full"
placeholder="Buscar empleado por nombre o ID"
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
import Button from 'primevue/button';
import FixedAssetGeneralInfoSection from './FixedAssetGeneralInfoSection.vue';
import FixedAssetAcquisitionSection from './FixedAssetAcquisitionSection.vue';
import FixedAssetAssignmentSection from './FixedAssetAssignmentSection.vue';
import FixedAssetImageCard from './FixedAssetImageCard.vue';
import type { FixedAssetFormData } from '../../types/fixedAsset';
const router = useRouter();
const toast = useToast();
const saving = ref(false);
const form = ref<FixedAssetFormData>({
name: '',
serial: '',
category: '',
brand: '',
model: '',
purchaseDate: '',
purchasePrice: null,
initialLocation: '',
initialStatus: 'Disponible',
responsibleEmployee: '',
imageFileName: ''
});
const cancel = () => {
router.push('/fixed-assets');
};
const saveAsset = async () => {
if (!form.value.name || !form.value.serial) {
toast.add({
severity: 'warn',
summary: 'Campos requeridos',
detail: 'Completa nombre y numero de serie del activo.',
life: 3000
});
return;
}
saving.value = true;
await new Promise((resolve) => setTimeout(resolve, 500));
saving.value = false;
toast.add({
severity: 'success',
summary: 'Activo registrado',
detail: `El activo "${form.value.name}" se registro correctamente.`,
life: 2600
});
router.push('/fixed-assets');
};
</script>
<template>
<section class="space-y-6">
<Toast position="bottom-right" />
<div>
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Registrar Nuevo Activo Fijo
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Complete los detalles para dar de alta un nuevo activo en el inventario global del almacen.
</p>
</div>
<FixedAssetGeneralInfoSection :form="form" />
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div class="space-y-5 xl:col-span-8">
<FixedAssetAcquisitionSection :form="form" />
<FixedAssetAssignmentSection :form="form" />
</div>
<div class="xl:col-span-4">
<FixedAssetImageCard :form="form" />
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<Button label="Cancelar" text severity="secondary" @click="cancel" />
<Button
label="Guardar Registro"
icon="pi pi-save"
:loading="saving"
@click="saveAsset"
/>
</div>
</section>
</template>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import type { FixedAssetFormData } from '../../types/fixedAsset';
interface Props {
form: FixedAssetFormData;
}
defineProps<Props>();
const categoryOptions = [
{ label: 'Seleccione una categoria', value: '' },
{ label: 'Computo', value: 'Computo' },
{ label: 'Maquinaria', value: 'Maquinaria' },
{ label: 'Mobiliario', value: 'Mobiliario' },
{ label: 'Infraestructura TI', value: 'Infraestructura TI' }
];
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-info-circle text-primary"></i>
<span>Informacion General</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Nombre del Activo *
</label>
<InputText
v-model="form.name"
class="w-full"
placeholder="Ej: Montacargas Electrico Toyota"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Numero de Serie / Serial *
</label>
<InputText
v-model="form.serial"
class="w-full"
placeholder="SN-123456789"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Categoria
</label>
<Select
v-model="form.category"
:options="categoryOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Marca
</label>
<InputText
v-model="form.brand"
class="w-full"
placeholder="Ej: Toyota, Dell, Bosch"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Modelo
</label>
<InputText
v-model="form.model"
class="w-full"
placeholder="Ej: Series-X 2023"
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import Card from 'primevue/card';
import FileUpload from 'primevue/fileupload';
import type { FixedAssetFormData } from '../../types/fixedAsset';
interface Props {
form: FixedAssetFormData;
}
const props = defineProps<Props>();
const onSelectFile = (event: { files: File[] }) => {
const file = event.files?.[0];
props.form.imageFileName = file ? file.name : '';
};
</script>
<template>
<Card class="shadow-sm h-full">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-image text-primary"></i>
<span>Imagen del Activo</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<FileUpload
mode="basic"
name="fixed-asset-image"
accept="image/png,image/jpeg"
:maxFileSize="5000000"
chooseLabel="Subir Imagen"
class="w-full"
@select="onSelectFile"
/>
<div class="upload-dropzone">
<i class="pi pi-upload text-3xl text-surface-400"></i>
<p class="mt-2 text-sm font-semibold text-surface-700 dark:text-surface-200">Subir Imagen</p>
<p class="mt-1 text-xs text-surface-500 dark:text-surface-400">PNG, JPG hasta 5MB</p>
</div>
<div class="rounded-lg bg-surface-100 px-3 py-2 text-xs text-surface-500 dark:bg-surface-800 dark:text-surface-400">
{{ form.imageFileName || 'No seleccionado' }}
</div>
</div>
</template>
</Card>
</template>
<style scoped>
.upload-dropzone {
border: 1px dashed var(--p-surface-300);
border-radius: 0.75rem;
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
}
</style>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue';
import Card from 'primevue/card';
import Select from 'primevue/select';
import type { AssignmentAssetOption } from '../../types/fixedAssetAssignment';
interface Props {
assets: AssignmentAssetOption[];
searchTerm: string;
selectedAssetId: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:searchTerm', value: string): void;
(e: 'update:selectedAssetId', value: string): void;
}>();
const selectedAsset = computed(() =>
props.assets.find((asset) => asset.id === props.selectedAssetId) ?? null
);
const selectOptions = computed(() =>
props.assets.map((asset) => ({
label: `${asset.name} - ${asset.serial}`,
value: asset.id
}))
);
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-folder-open text-primary"></i>
<span>Seleccionar Activo</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<Select
:model-value="selectedAssetId"
:options="selectOptions"
optionLabel="label"
optionValue="value"
class="w-full"
placeholder="Busque o seleccione un activo por nombre o serie..."
filter
@update:model-value="emit('update:selectedAssetId', $event)"
/>
<div class="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-900/40 dark:bg-blue-900/15">
<div class="flex items-start gap-3">
<div class="mt-1 flex h-10 w-10 items-center justify-center rounded-lg bg-white dark:bg-surface-900">
<i class="pi pi-image text-primary"></i>
</div>
<div v-if="selectedAsset">
<p class="font-semibold text-surface-900 dark:text-surface-0">
{{ selectedAsset.name }}
</p>
<p class="text-sm text-surface-600 dark:text-surface-300">
{{ selectedAsset.code }} - {{ selectedAsset.serial }}
</p>
<p class="text-xs text-surface-500 dark:text-surface-400">
{{ selectedAsset.category }}
</p>
</div>
<div v-else>
<p class="font-semibold text-surface-900 dark:text-surface-0">Activo seleccionado</p>
<p class="text-sm text-surface-600 dark:text-surface-300">
Seleccione un activo arriba para ver los detalles aqui.
</p>
</div>
</div>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import Textarea from 'primevue/textarea';
import type { FixedAssetAssignmentFormData } from '../../types/fixedAssetAssignment';
interface Props {
form: FixedAssetAssignmentFormData;
}
defineProps<Props>();
const conditionOptions = [
{ label: 'Excelente', value: 'Excelente' },
{ label: 'Bueno', value: 'Bueno' },
{ label: 'Regular', value: 'Regular' }
];
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-clipboard text-primary"></i>
<span>Detalles de la Asignacion</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Fecha de Entrega</label>
<InputText
v-model="form.deliveredAt"
type="date"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Condicion del Activo</label>
<Select
v-model="form.condition"
:options="conditionOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Notas o Comentarios (Opcional)</label>
<Textarea
v-model="form.notes"
rows="4"
class="w-full"
autoResize
placeholder="Anade detalles relevantes sobre el estado de entrega o terminos especificos..."
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue';
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import type { AssignmentEmployeeOption } from '../../types/fixedAssetAssignment';
interface Props {
employees: AssignmentEmployeeOption[];
searchTerm: string;
selectedEmployeeId: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:searchTerm', value: string): void;
(e: 'update:selectedEmployeeId', value: string): void;
}>();
const visibleEmployees = computed(() => {
const query = props.searchTerm.trim().toLowerCase();
if (!query) return props.employees;
return props.employees.filter((employee) =>
employee.fullName.toLowerCase().includes(query)
|| employee.id.toLowerCase().includes(query)
|| employee.department.toLowerCase().includes(query)
);
});
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-users text-primary"></i>
<span>Seleccionar Colaborador</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<InputText
:model-value="searchTerm"
class="w-full"
placeholder="Buscar por nombre, ID o departamento..."
@update:model-value="emit('update:searchTerm', String($event ?? ''))"
/>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<button
v-for="employee in visibleEmployees"
:key="employee.id"
type="button"
class="rounded-xl border p-4 text-left transition-all"
:class="selectedEmployeeId === employee.id
? 'border-primary bg-primary-50 dark:bg-primary-950/20'
: 'border-surface-200 hover:border-primary/50 dark:border-surface-700'"
@click="emit('update:selectedEmployeeId', employee.id)"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-sm font-semibold text-white">
{{ employee.initials }}
</div>
<div>
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ employee.fullName }}</p>
<p class="text-sm text-surface-600 dark:text-surface-300">{{ employee.role }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">{{ employee.department }}</p>
</div>
</div>
<i
class="pi pi-check-circle text-primary"
:class="selectedEmployeeId === employee.id ? 'opacity-100' : 'opacity-0'"
/>
</div>
</button>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
import Button from 'primevue/button';
import AssignmentAssetSelectorCard from './AssignmentAssetSelectorCard.vue';
import AssignmentEmployeeSelectorCard from './AssignmentEmployeeSelectorCard.vue';
import AssignmentDetailsCard from './AssignmentDetailsCard.vue';
import type {
AssignmentAssetOption,
AssignmentEmployeeOption,
FixedAssetAssignmentFormData
} from '../../types/fixedAssetAssignment';
const router = useRouter();
const toast = useToast();
const loading = ref(false);
const assetSearch = ref('');
const employeeSearch = ref('');
const assets = ref<AssignmentAssetOption[]>([
{ id: '1', code: 'ACT-0001', name: 'Laptop Dell XPS 15', serial: 'DELL-8829-XP', category: 'Computo' },
{ id: '2', code: 'ACT-0042', name: 'Montacargas Hidraulico', serial: 'MH-1029', category: 'Maquinaria' },
{ id: '3', code: 'ACT-0056', name: 'Escritorio Ergonomico', serial: 'MOB-221', category: 'Mobiliario' }
]);
const employees = ref<AssignmentEmployeeOption[]>([
{ id: 'EMP-001', initials: 'RM', fullName: 'Roberto Mendez', role: 'Operador de Almacen', department: 'Logistica' },
{ id: 'EMP-008', initials: 'AS', fullName: 'Ana Salinas', role: 'Supervisora de Piso', department: 'Operaciones' },
{ id: 'EMP-017', initials: 'CG', fullName: 'Carlos Guerrero', role: 'Tecnico de Mantenimiento', department: 'Mantenimiento' }
]);
const form = ref<FixedAssetAssignmentFormData>({
assetId: '',
employeeId: '',
deliveredAt: new Date().toISOString().slice(0, 10),
condition: 'Excelente',
notes: ''
});
const filteredAssets = computed(() => {
const query = assetSearch.value.trim().toLowerCase();
if (!query) return assets.value;
return assets.value.filter((asset) =>
asset.name.toLowerCase().includes(query)
|| asset.serial.toLowerCase().includes(query)
|| asset.code.toLowerCase().includes(query)
);
});
const cancel = () => router.push('/fixed-assets/assignments');
const save = async () => {
if (!form.value.assetId || !form.value.employeeId) {
toast.add({
severity: 'warn',
summary: 'Campos pendientes',
detail: 'Seleccione activo y colaborador para confirmar la asignacion.',
life: 3000
});
return;
}
loading.value = true;
await new Promise((resolve) => setTimeout(resolve, 500));
loading.value = false;
toast.add({
severity: 'success',
summary: 'Asignacion registrada',
detail: 'La asignacion de activo al colaborador se registro correctamente.',
life: 2500
});
router.push('/fixed-assets/assignments');
};
</script>
<template>
<section class="space-y-6">
<Toast position="bottom-right" />
<div>
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Asignacion de Activo a Empleado
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Registre la entrega de una herramienta o equipo a un colaborador del almacen.
</p>
</div>
<AssignmentAssetSelectorCard
:assets="filteredAssets"
:search-term="assetSearch"
:selected-asset-id="form.assetId"
@update:search-term="assetSearch = $event"
@update:selected-asset-id="form.assetId = $event"
/>
<AssignmentEmployeeSelectorCard
:employees="employees"
:search-term="employeeSearch"
:selected-employee-id="form.employeeId"
@update:search-term="employeeSearch = $event"
@update:selected-employee-id="form.employeeId = $event"
/>
<AssignmentDetailsCard :form="form" />
<div class="flex flex-wrap items-center justify-end gap-3">
<Button label="Cancelar" text severity="secondary" @click="cancel" />
<Button
label="Confirmar Asignacion"
icon="pi pi-check-circle"
:loading="loading"
@click="save"
/>
</div>
</section>
</template>

View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
import Button from 'primevue/button';
import AssignmentOffboardingSummaryCard from './offboarding/AssignmentOffboardingSummaryCard.vue';
import AssignmentOffboardingEventCard from './offboarding/AssignmentOffboardingEventCard.vue';
import AssignmentOffboardingEvidenceCard from './offboarding/AssignmentOffboardingEvidenceCard.vue';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const loading = ref(false);
const defaultAssignment = {
id: 'AS-DEFAULT',
assetCode: 'WH-NA-000',
assetName: 'Activo no encontrado',
serial: 'N/A',
custodian: 'Sin custodio'
};
const assignmentsMock = [
{
id: 'AS-00124',
assetCode: 'WH-LAP-2023-042',
assetName: `MacBook Pro 14" M2 Max`,
serial: 'SN-48291048-X',
custodian: 'Carlos Rodriguez'
},
{
id: 'AS-00125',
assetCode: 'WH-LAP-2023-051',
assetName: 'Laptop Latitude 5420',
serial: 'SN-22345008-K',
custodian: 'Ana Salinas'
}
];
const currentAssignment = computed(() => {
const id = String(route.params.id || '').replace('#', '');
return assignmentsMock.find((item) => item.id === id) ?? assignmentsMock[0] ?? defaultAssignment;
});
const form = ref({
reason: '',
happenedAt: new Date().toISOString().slice(0, 10),
details: '',
finalStatus: '',
evidenceFileName: ''
});
const cancel = () => router.push('/fixed-assets/assignments');
const confirmOffboarding = async () => {
if (!form.value.reason || !form.value.happenedAt || !form.value.details || !form.value.finalStatus) {
toast.add({
severity: 'warn',
summary: 'Campos obligatorios',
detail: 'Complete motivo, fecha, descripcion y estado final.',
life: 3000
});
return;
}
loading.value = true;
await new Promise((resolve) => setTimeout(resolve, 600));
loading.value = false;
toast.add({
severity: 'success',
summary: 'Baja confirmada',
detail: 'La baja de asignacion se registro correctamente.',
life: 2500
});
router.push('/fixed-assets/assignments');
};
</script>
<template>
<section class="space-y-6">
<Toast position="bottom-right" />
<div>
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Baja de Asignacion de Activo
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Complete el formulario para desvincular el activo del custodio actual y registrar el motivo de la baja.
</p>
</div>
<AssignmentOffboardingSummaryCard
:asset-code="currentAssignment.assetCode"
:asset-name="currentAssignment.assetName"
:serial="currentAssignment.serial"
:custodian="currentAssignment.custodian"
/>
<AssignmentOffboardingEventCard :form="form" />
<AssignmentOffboardingEvidenceCard :form="form" />
<div class="flex flex-wrap items-center justify-end gap-3">
<Button label="Cancelar" text severity="secondary" @click="cancel" />
<Button
label="Confirmar Baja"
icon="pi pi-times-circle"
severity="danger"
:loading="loading"
@click="confirmOffboarding"
/>
</div>
</section>
</template>

View File

@ -0,0 +1,249 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import Button from 'primevue/button';
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import Paginator from 'primevue/paginator';
import Tag from 'primevue/tag';
interface AssignmentRow {
id: string;
assetName: string;
assetSerial: string;
employeeName: string;
department: string;
deliveryDate: string;
expectedReturn: string;
condition: 'Excelente' | 'Bueno' | 'Regular';
status: 'Activo' | 'Vencido' | 'Devuelto';
}
const router = useRouter();
const searchTerm = ref('');
const selectedStatus = ref<'all' | AssignmentRow['status']>('all');
const first = ref(0);
const rows = ref(5);
const statusOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Activo', value: 'Activo' },
{ label: 'Vencido', value: 'Vencido' },
{ label: 'Devuelto', value: 'Devuelto' }
];
const assignments = ref<AssignmentRow[]>([
{
id: '#AS-00124',
assetName: 'Montacargas Electrico',
assetSerial: 'TY-98231',
employeeName: 'Roberto Mendez',
department: 'Logistica',
deliveryDate: '20/Oct/2023',
expectedReturn: 'N/A',
condition: 'Excelente',
status: 'Activo'
},
{
id: '#AS-00125',
assetName: 'Laptop Latitude 5420',
assetSerial: 'DL-22345',
employeeName: 'Ana Salinas',
department: 'Administracion',
deliveryDate: '15/Nov/2023',
expectedReturn: '15/Dic/2023',
condition: 'Bueno',
status: 'Vencido'
},
{
id: '#AS-00126',
assetName: 'Escaner Zebra',
assetSerial: 'ZB-77788',
employeeName: 'Carlos Ruiz',
department: 'Almacen',
deliveryDate: '10/Oct/2023',
expectedReturn: '10/Nov/2023',
condition: 'Regular',
status: 'Devuelto'
},
{
id: '#AS-00127',
assetName: 'Tablet Samsung A8',
assetSerial: 'SM-99288',
employeeName: 'Lucia Ponce',
department: 'Operaciones',
deliveryDate: '02/Ene/2024',
expectedReturn: 'N/A',
condition: 'Excelente',
status: 'Activo'
}
]);
const conditionSeverity = (condition: AssignmentRow['condition']) => {
if (condition === 'Excelente') return 'success';
if (condition === 'Bueno') return 'warning';
return 'secondary';
};
const statusSeverity = (status: AssignmentRow['status']) => {
if (status === 'Activo') return 'info';
if (status === 'Vencido') return 'danger';
return 'secondary';
};
const filteredAssignments = computed(() => {
const query = searchTerm.value.trim().toLowerCase();
return assignments.value.filter((assignment) => {
const matchesQuery = !query
|| assignment.assetName.toLowerCase().includes(query)
|| assignment.employeeName.toLowerCase().includes(query)
|| assignment.id.toLowerCase().includes(query);
const matchesStatus = selectedStatus.value === 'all' || assignment.status === selectedStatus.value;
return matchesQuery && matchesStatus;
});
});
const paginatedAssignments = computed(() =>
filteredAssignments.value.slice(first.value, first.value + rows.value)
);
const onPage = (event: { first: number; rows: number }) => {
first.value = event.first;
rows.value = event.rows;
};
const goToCreate = () => {
router.push('/fixed-assets/assignments/create');
};
const goToOffboarding = (assignmentId: string) => {
const cleanId = assignmentId.replace('#', '');
router.push(`/fixed-assets/assignments/${cleanId}/offboarding`);
};
</script>
<template>
<section class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="mt-2 text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Listado de Asignaciones
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Gestione y rastree las entregas de equipos al personal.
</p>
</div>
<Button label="Nueva Asignacion" icon="pi pi-plus" @click="goToCreate" />
</div>
<Card class="shadow-sm">
<template #content>
<div class="space-y-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<InputText
v-model="searchTerm"
class="w-full lg:max-w-sm"
placeholder="Buscar por activo o empleado..."
/>
<div class="flex w-full flex-wrap items-center gap-2 lg:w-auto">
<span class="text-sm font-medium text-surface-600 dark:text-surface-300">
Estatus de Asignacion:
</span>
<Select
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full lg:w-44"
/>
<Button
label="Filtros Avanzados"
icon="pi pi-filter"
severity="secondary"
outlined
/>
</div>
</div>
<div class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="min-w-full border-collapse">
<thead>
<tr class="bg-surface-50 text-left text-xs font-semibold uppercase tracking-wide text-surface-500 dark:bg-surface-800 dark:text-surface-300">
<th class="px-4 py-3">ID Asignacion</th>
<th class="px-4 py-3">Activo</th>
<th class="px-4 py-3">Empleado</th>
<th class="px-4 py-3">Entrega</th>
<th class="px-4 py-3">Retorno Previsto</th>
<th class="px-4 py-3">Condicion</th>
<th class="px-4 py-3">Estado</th>
<th class="px-4 py-3 text-right">Accion</th>
</tr>
</thead>
<tbody>
<tr
v-for="assignment in paginatedAssignments"
:key="assignment.id"
class="border-t border-surface-200 text-sm dark:border-surface-700"
>
<td class="px-4 py-3 font-medium text-surface-800 dark:text-surface-100">
{{ assignment.id }}
</td>
<td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ assignment.assetName }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">SN: {{ assignment.assetSerial }}</p>
</td>
<td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ assignment.employeeName }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">{{ assignment.department }}</p>
</td>
<td class="px-4 py-3">{{ assignment.deliveryDate }}</td>
<td class="px-4 py-3">{{ assignment.expectedReturn }}</td>
<td class="px-4 py-3">
<Tag :value="assignment.condition" :severity="conditionSeverity(assignment.condition)" />
</td>
<td class="px-4 py-3">
<Tag :value="assignment.status" :severity="statusSeverity(assignment.status)" />
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button icon="pi pi-eye" text rounded size="small" />
<Button
icon="pi pi-times-circle"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'Dar de baja asignacion'"
@click="goToOffboarding(assignment.id)"
/>
</div>
</td>
</tr>
<tr v-if="paginatedAssignments.length === 0">
<td colspan="8" class="px-4 py-8 text-center text-surface-500 dark:text-surface-400">
No hay asignaciones para mostrar.
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<p class="text-sm text-surface-500 dark:text-surface-400">
Mostrando {{ paginatedAssignments.length }} de {{ filteredAssignments.length }} asignaciones
</p>
<Paginator
:first="first"
:rows="rows"
:totalRecords="filteredAssignments.length"
:rowsPerPageOptions="[5, 10, 20]"
template="PrevPageLink PageLinks NextPageLink"
@page="onPage"
/>
</div>
</div>
</template>
</Card>
</section>
</template>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import Card from 'primevue/card';
import Select from 'primevue/select';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
interface OffboardingEventForm {
reason: string;
happenedAt: string;
details: string;
}
interface Props {
form: OffboardingEventForm;
}
defineProps<Props>();
const reasonOptions = [
{ label: 'Seleccione un motivo', value: '' },
{ label: 'Cambio de colaborador', value: 'Cambio de colaborador' },
{ label: 'Baja por danio', value: 'Baja por danio' },
{ label: 'Robo o extravio', value: 'Robo o extravio' },
{ label: 'Fin de proyecto', value: 'Fin de proyecto' }
];
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-file-edit text-red-500"></i>
<span>2. Detalles del Evento</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Motivo de la Baja *</label>
<Select
v-model="form.reason"
:options="reasonOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Fecha del Suceso *</label>
<InputText v-model="form.happenedAt" type="date" class="w-full" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Descripcion Detallada *</label>
<Textarea
v-model="form.details"
rows="4"
class="w-full"
autoResize
placeholder="Proporcione detalles especificos sobre el fallo o la razon de la desvinculacion..."
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import Card from 'primevue/card';
import FileUpload from 'primevue/fileupload';
import Select from 'primevue/select';
interface OffboardingEvidenceForm {
finalStatus: string;
evidenceFileName: string;
}
interface Props {
form: OffboardingEvidenceForm;
}
const props = defineProps<Props>();
const finalStatusOptions = [
{ label: 'Seleccione el destino del activo', value: '' },
{ label: 'Devuelto a almacen', value: 'Devuelto a almacen' },
{ label: 'En reparacion', value: 'En reparacion' },
{ label: 'Baja definitiva', value: 'Baja definitiva' },
{ label: 'Reasignacion pendiente', value: 'Reasignacion pendiente' }
];
const onSelectEvidence = (event: { files: File[] }) => {
const file = event.files?.[0];
props.form.evidenceFileName = file ? file.name : '';
};
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-file-plus text-red-500"></i>
<span>3. Evidencia y Estado Final</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<div>
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Archivos de Evidencia (Fotos, Reportes, PDFs)
</p>
<div class="mt-2 rounded-xl border border-dashed border-surface-300 p-6 text-center dark:border-surface-700">
<FileUpload
mode="basic"
name="offboarding-evidence"
accept="image/png,image/jpeg,application/pdf"
:maxFileSize="10000000"
chooseLabel="Subir evidencia"
class="w-full"
@select="onSelectEvidence"
/>
<p class="mt-3 text-xs text-surface-500 dark:text-surface-400">
Soportado: JPG, PNG, PDF (Max. 10MB)
</p>
<p class="mt-2 text-xs font-medium text-surface-600 dark:text-surface-300">
{{ form.evidenceFileName || 'Sin archivo seleccionado' }}
</p>
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Estado Final del Activo *</label>
<Select
v-model="form.finalStatus"
:options="finalStatusOptions"
optionLabel="label"
optionValue="value"
class="w-full md:w-80"
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import Card from 'primevue/card';
interface Props {
assetCode: string;
assetName: string;
serial: string;
custodian: string;
}
defineProps<Props>();
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-exclamation-circle text-red-500"></i>
<span>1. Resumen de Asignacion Actual</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-500">ID de Activo</p>
<p class="mt-1 font-medium text-surface-900 dark:text-surface-0">{{ assetCode }}</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-500">Descripcion</p>
<p class="mt-1 font-medium text-surface-900 dark:text-surface-0">{{ assetName }}</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-500">Numero de Serie</p>
<p class="mt-1 font-medium text-surface-900 dark:text-surface-0">{{ serial }}</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-500">Custodio Actual</p>
<p class="mt-1 font-medium text-surface-900 dark:text-surface-0">{{ custodian }}</p>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Breadcrumb from 'primevue/breadcrumb';
import Button from 'primevue/button';
import StructureParentInfoCard from './StructureParentInfoCard.vue';
import StructureContentsTable from './StructureContentsTable.vue';
import StructureValuationSummary from './StructureValuationSummary.vue';
import StructureControlInfoCard from './StructureControlInfoCard.vue';
import { fixedAssetStructuresService } from '../../services/fixedAssetStructuresService';
import type { FixedAssetStructure } from '../../types/fixedAssetStructure';
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const structure = ref<FixedAssetStructure | null>(null);
const breadcrumbItems = computed(() => [
{ label: 'Inicio', route: '/' },
{ label: 'Estructuras de Activos', route: '/fixed-assets/structures' },
{ label: structure.value?.code ?? 'Detalle' }
]);
const home = { icon: 'pi pi-home', route: '/' };
const loadDetails = async () => {
const id = String(route.params.id);
structure.value = await fixedAssetStructuresService.getStructureById(id);
loading.value = false;
};
const goToEdit = () => {
if (!structure.value) return;
router.push(`/fixed-assets/structures/${structure.value.id}/edit`);
};
onMounted(loadDetails);
</script>
<template>
<section class="space-y-5">
<Breadcrumb :home="home" :model="breadcrumbItems" />
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Detalle de Estructura de Activo
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Consulta el arbol de relacion y datos de control del activo compuesto.
</p>
</div>
<Button label="Editar estructura" icon="pi pi-pencil" :disabled="!structure" @click="goToEdit" />
</div>
<div v-if="loading" class="rounded-xl border border-surface-200 bg-surface-0 p-8 text-center text-surface-500 dark:border-surface-700 dark:bg-surface-900">
Cargando informacion de la estructura...
</div>
<div v-else-if="structure" class="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div class="space-y-5 xl:col-span-8">
<StructureParentInfoCard :structure="structure" readOnly />
<StructureContentsTable :model-value="structure.contents" readOnly />
</div>
<div class="space-y-5 xl:col-span-4">
<StructureValuationSummary :structure="structure" />
<StructureControlInfoCard :structure="structure" />
</div>
</div>
<div v-else class="rounded-xl border border-surface-200 bg-surface-0 p-8 text-center text-surface-500 dark:border-surface-700 dark:bg-surface-900">
No se encontro la estructura solicitada.
</div>
</section>
</template>

View File

@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Button from 'primevue/button';
import Breadcrumb from 'primevue/breadcrumb';
import InputNumber from 'primevue/inputnumber';
import Card from 'primevue/card';
import StructureParentInfoCard from './StructureParentInfoCard.vue';
import StructureValuationSummary from './StructureValuationSummary.vue';
import StructureControlInfoCard from './StructureControlInfoCard.vue';
import StructureContentsTable from './StructureContentsTable.vue';
import { fixedAssetStructuresService } from '../../services/fixedAssetStructuresService';
import type { FixedAssetStructure } from '../../types/fixedAssetStructure';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const emptyStructure = (): FixedAssetStructure => ({
id: '',
code: 'NUEVA',
name: '',
containerCategory: 'Oficina Movil',
containerSerial: '',
location: '',
containerValue: 0,
status: 'BORRADOR',
controlInfo: {
statusLabel: 'Borrador / En proceso',
createdAt: new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' }),
owner: 'Usuario actual'
},
contents: []
});
const structure = ref<FixedAssetStructure>(emptyStructure());
const isEditing = computed(() => Boolean(route.params.id));
const title = computed(() =>
isEditing.value ? 'Editar Estructura de Activo Compuesto' : 'Crear Estructura de Activo Compuesto'
);
const breadcrumbItems = computed(() => [
{ label: 'Inicio', route: '/' },
{ label: 'Gestion de Activos', route: '/fixed-assets' },
{ label: isEditing.value ? 'Editar Estructura' : 'Crear Estructura' }
]);
const home = { icon: 'pi pi-home', route: '/' };
const loadStructure = async () => {
if (!route.params.id) return;
loading.value = true;
const found = await fixedAssetStructuresService.getStructureById(String(route.params.id));
if (found) {
structure.value = JSON.parse(JSON.stringify(found)) as FixedAssetStructure;
}
loading.value = false;
};
const saveStructure = () => {
router.push('/fixed-assets/structures');
};
const cancel = () => {
router.back();
};
onMounted(loadStructure);
</script>
<template>
<section class="space-y-5">
<Breadcrumb :home="home" :model="breadcrumbItems" />
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
{{ title }}
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Configure la jerarquia de activos y asigne los elementos contenidos en el contenedor principal.
</p>
</div>
<div class="flex gap-2">
<Button label="Cancelar" outlined severity="secondary" @click="cancel" />
<Button label="Guardar estructura" icon="pi pi-check" :loading="loading" @click="saveStructure" />
</div>
</div>
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div class="space-y-5 xl:col-span-8">
<StructureParentInfoCard :structure="structure" />
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-wallet text-primary"></i>
<span>Valor del Activo Padre</span>
</div>
</template>
<template #content>
<div class="max-w-sm space-y-2">
<label class="text-xs font-semibold uppercase tracking-wide text-surface-500">
Valor de contenedor
</label>
<InputNumber
v-model="structure.containerValue"
mode="currency"
currency="MXN"
locale="es-MX"
class="w-full"
/>
</div>
</template>
</Card>
<StructureContentsTable v-model="structure.contents" />
</div>
<div class="space-y-5 xl:col-span-4">
<StructureValuationSummary :structure="structure" />
<StructureControlInfoCard :structure="structure" />
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Card from 'primevue/card';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import Select from 'primevue/select';
import Paginator from 'primevue/paginator';
import { fixedAssetStructuresService } from '../../services/fixedAssetStructuresService';
import type { FixedAssetStructure, StructureStatus } from '../../types/fixedAssetStructure';
const router = useRouter();
const loading = ref(false);
const first = ref(0);
const rows = ref(6);
const searchTerm = ref('');
const selectedStatus = ref<'all' | StructureStatus>('all');
const structures = ref<FixedAssetStructure[]>([]);
const statusOptions = [
{ label: 'Todos los estatus', value: 'all' },
{ label: 'Borrador', value: 'BORRADOR' },
{ label: 'Activa', value: 'ACTIVA' },
{ label: 'En revision', value: 'EN_REVISION' },
{ label: 'Inactiva', value: 'INACTIVA' }
];
const statusClasses: Record<StructureStatus, string> = {
BORRADOR: 'bg-surface-200 text-surface-700',
ACTIVA: 'bg-emerald-100 text-emerald-700',
EN_REVISION: 'bg-amber-100 text-amber-700',
INACTIVA: 'bg-red-100 text-red-700'
};
const formatCurrency = (value: number) =>
`$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const filteredStructures = computed(() => {
const query = searchTerm.value.trim().toLowerCase();
return structures.value.filter((structure) => {
const matchesQuery = !query
|| structure.code.toLowerCase().includes(query)
|| structure.name.toLowerCase().includes(query)
|| structure.containerSerial.toLowerCase().includes(query);
const matchesStatus = selectedStatus.value === 'all' || structure.status === selectedStatus.value;
return matchesQuery && matchesStatus;
});
});
const paginatedStructures = computed(() =>
filteredStructures.value.slice(first.value, first.value + rows.value)
);
const loadStructures = async () => {
loading.value = true;
structures.value = await fixedAssetStructuresService.getStructures();
loading.value = false;
};
const onPage = (event: { first: number; rows: number }) => {
first.value = event.first;
rows.value = event.rows;
};
const goToCreate = () => router.push('/fixed-assets/structures/create');
const goToDetails = (id: string) => router.push(`/fixed-assets/structures/${id}`);
const goToEdit = (id: string) => router.push(`/fixed-assets/structures/${id}/edit`);
onMounted(loadStructures);
</script>
<template>
<section class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Estructuras de Activos Fijos
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Define, organiza y monitorea activos compuestos.
</p>
</div>
<Button label="Crear estructura" icon="pi pi-plus" @click="goToCreate" />
</div>
<Card class="shadow-sm">
<template #content>
<div class="space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<IconField iconPosition="left" class="w-full md:max-w-lg">
<InputIcon class="pi pi-search" />
<InputText
v-model="searchTerm"
class="w-full"
placeholder="Buscar por codigo, nombre o numero de serie..."
/>
</IconField>
<Select
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full md:w-56"
/>
</div>
<div class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="min-w-full border-collapse">
<thead>
<tr class="bg-surface-50 text-left text-xs font-semibold uppercase tracking-wide text-surface-500 dark:bg-surface-800 dark:text-surface-300">
<th class="px-4 py-3">Codigo</th>
<th class="px-4 py-3">Estructura</th>
<th class="px-4 py-3">Categoria</th>
<th class="px-4 py-3">Ubicacion</th>
<th class="px-4 py-3">Contenidos</th>
<th class="px-4 py-3">Valor Total</th>
<th class="px-4 py-3">Estatus</th>
<th class="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="8" class="px-4 py-8 text-center text-sm text-surface-500">
Cargando estructuras...
</td>
</tr>
<tr
v-for="structure in paginatedStructures"
:key="structure.id"
class="border-t border-surface-200 text-sm dark:border-surface-700"
>
<td class="px-4 py-3 font-mono text-xs text-primary">{{ structure.code }}</td>
<td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ structure.name }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">Serie: {{ structure.containerSerial }}</p>
</td>
<td class="px-4 py-3">{{ structure.containerCategory }}</td>
<td class="px-4 py-3">{{ structure.location }}</td>
<td class="px-4 py-3 text-center font-semibold">{{ structure.contents.length }}</td>
<td class="px-4 py-3 font-semibold">{{ formatCurrency(structure.containerValue + structure.contents.reduce((sum, item) => sum + item.value, 0)) }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full px-2 py-1 text-xs font-semibold" :class="statusClasses[structure.status]">
{{ structure.status }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1">
<Button icon="pi pi-eye" text rounded size="small" @click="goToDetails(structure.id)" />
<Button icon="pi pi-pencil" text rounded size="small" @click="goToEdit(structure.id)" />
</div>
</td>
</tr>
<tr v-if="!loading && paginatedStructures.length === 0">
<td colspan="8" class="px-4 py-8 text-center text-sm text-surface-500 dark:text-surface-400">
No hay estructuras para mostrar.
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<p class="text-sm text-surface-500 dark:text-surface-400">
Mostrando {{ paginatedStructures.length }} de {{ filteredStructures.length }} estructuras
</p>
<Paginator
:first="first"
:rows="rows"
:totalRecords="filteredStructures.length"
:rowsPerPageOptions="[6, 12, 18]"
template="PrevPageLink PageLinks NextPageLink"
@page="onPage"
/>
</div>
</div>
</template>
</Card>
</section>
</template>

View File

@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import Card from 'primevue/card';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import type { ContentCondition, StructureContentAsset } from '../../types/fixedAssetStructure';
interface Props {
modelValue: StructureContentAsset[];
readOnly?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:modelValue', value: StructureContentAsset[]): void;
}>();
const serialQuery = ref('');
const availableAssets: StructureContentAsset[] = [
{
id: 'ACT-5001',
name: 'Monitor Curvo 34"',
category: 'Perifericos',
serial: 'MON-CV-4491',
condition: 'EXCELENTE',
value: 9850
},
{
id: 'ACT-5002',
name: 'Docking Station USB-C',
category: 'Perifericos',
serial: 'DCK-USB-112',
condition: 'BUENO',
value: 3150
},
{
id: 'ACT-5003',
name: 'Gabinete de Red 24U',
category: 'Infraestructura TI',
serial: 'NET-CAB-240',
condition: 'REGULAR',
value: 6250
}
];
const conditionClasses: Record<ContentCondition, string> = {
EXCELENTE: 'bg-emerald-100 text-emerald-700',
BUENO: 'bg-amber-100 text-amber-700',
REGULAR: 'bg-orange-100 text-orange-700'
};
const totalValue = computed(() =>
props.modelValue.reduce((sum, item) => sum + item.value, 0)
);
const formatCurrency = (value: number) =>
`$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const addAsset = () => {
const query = serialQuery.value.trim().toLowerCase();
if (!query) return;
const candidate = availableAssets.find((asset) =>
asset.serial.toLowerCase().includes(query) || asset.id.toLowerCase().includes(query)
);
if (!candidate) return;
const exists = props.modelValue.some((item) => item.id === candidate.id);
if (exists) return;
emit('update:modelValue', [...props.modelValue, candidate]);
serialQuery.value = '';
};
const removeAsset = (assetId: string) => {
emit('update:modelValue', props.modelValue.filter((item) => item.id !== assetId));
};
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex flex-wrap items-center justify-between gap-2 text-xl">
<div class="flex items-center gap-2">
<i class="pi pi-list-check text-primary"></i>
<span>Inventario de Activos Contenidos</span>
</div>
<span class="rounded-full bg-surface-100 px-3 py-1 text-xs font-semibold text-surface-600 dark:bg-surface-800 dark:text-surface-200">
{{ modelValue.length }} ELEMENTOS
</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<div class="flex flex-col gap-2 md:flex-row">
<InputText
v-model="serialQuery"
class="w-full"
:disabled="readOnly"
placeholder="Escriba o escanee numero de serie para anadir..."
@keyup.enter="addAsset"
/>
<Button
label="Agregar"
icon="pi pi-plus"
class="min-w-40"
:disabled="readOnly"
@click="addAsset"
/>
</div>
<p class="text-xs text-surface-500 dark:text-surface-400">
Tip: puede ingresar serie o ID del activo para anexarlo rapidamente.
</p>
<div class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="min-w-full border-collapse">
<thead>
<tr class="bg-surface-50 text-left text-xs font-semibold uppercase tracking-wide text-surface-500 dark:bg-surface-800 dark:text-surface-300">
<th class="px-4 py-3">Imagen</th>
<th class="px-4 py-3">Activo</th>
<th class="px-4 py-3">Serie</th>
<th class="px-4 py-3">Condicion</th>
<th class="px-4 py-3">Valor</th>
<th class="px-4 py-3 text-right">Accion</th>
</tr>
</thead>
<tbody>
<tr
v-for="asset in modelValue"
:key="asset.id"
class="border-t border-surface-200 text-sm dark:border-surface-700"
>
<td class="px-4 py-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-surface-100 dark:bg-surface-800">
<i class="pi pi-image text-surface-400"></i>
</div>
</td>
<td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ asset.name }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">{{ asset.category }}</p>
</td>
<td class="px-4 py-3 font-mono text-xs text-surface-600 dark:text-surface-300">{{ asset.serial }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full px-2 py-1 text-xs font-semibold" :class="conditionClasses[asset.condition]">
{{ asset.condition }}
</span>
</td>
<td class="px-4 py-3 font-semibold text-surface-900 dark:text-surface-0">
{{ formatCurrency(asset.value) }}
</td>
<td class="px-4 py-3 text-right">
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
size="small"
:disabled="readOnly"
@click="removeAsset(asset.id)"
/>
</td>
</tr>
<tr v-if="modelValue.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-sm text-surface-500 dark:text-surface-400">
No hay activos vinculados a la estructura.
</td>
</tr>
</tbody>
</table>
</div>
<div class="text-right">
<span class="text-sm text-surface-500 dark:text-surface-400">Valor acumulado de contenidos: </span>
<strong class="text-lg text-surface-900 dark:text-surface-0">{{ formatCurrency(totalValue) }}</strong>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import Card from 'primevue/card';
import type { FixedAssetStructure } from '../../types/fixedAssetStructure';
interface Props {
structure: FixedAssetStructure;
}
defineProps<Props>();
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-base">
<i class="pi pi-id-card text-primary"></i>
<span>Informacion de Control</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400">Estado de estructura</p>
<p class="mt-1 text-base font-semibold text-surface-900 dark:text-surface-0">{{ structure.controlInfo.statusLabel }}</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400">Fecha de creacion</p>
<p class="mt-1 text-base font-medium text-surface-800 dark:text-surface-100">{{ structure.controlInfo.createdAt }}</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400">Responsable</p>
<p class="mt-1 text-base font-medium text-surface-800 dark:text-surface-100">{{ structure.controlInfo.owner }}</p>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import type { FixedAssetStructure } from '../../types/fixedAssetStructure';
interface Props {
structure: FixedAssetStructure;
readOnly?: boolean;
}
defineProps<Props>();
const containerCategories = [
{ label: 'Oficina Movil', value: 'Oficina Movil' },
{ label: 'Punto de Venta', value: 'Punto de Venta' },
{ label: 'Infraestructura TI', value: 'Infraestructura TI' },
{ label: 'Contenedor Operativo', value: 'Contenedor Operativo' }
];
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-building-columns text-primary"></i>
<span>Informacion del Activo Padre</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-wide text-surface-500">
Nombre del contenedor
</label>
<InputText
v-model="structure.name"
class="w-full"
placeholder="Ej: Oficina Movil Mod-01"
:disabled="readOnly"
/>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-wide text-surface-500">
Categoria de contenedor
</label>
<Select
v-model="structure.containerCategory"
:options="containerCategories"
optionLabel="label"
optionValue="value"
class="w-full"
:disabled="readOnly"
/>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-wide text-surface-500">
Numero de serie
</label>
<InputText
v-model="structure.containerSerial"
class="w-full"
placeholder="SER-CONT-2024-001"
:disabled="readOnly"
/>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-wide text-surface-500">
Ubicacion fisica
</label>
<InputText
v-model="structure.location"
class="w-full"
placeholder="Planta Norte - Sector B"
:disabled="readOnly"
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import Card from 'primevue/card';
import { computed } from 'vue';
import type { FixedAssetStructure } from '../../types/fixedAssetStructure';
interface Props {
structure: FixedAssetStructure;
}
const props = defineProps<Props>();
const contentsTotal = computed(() =>
props.structure.contents.reduce((sum, item) => sum + item.value, 0)
);
const totalStructureValue = computed(() => props.structure.containerValue + contentsTotal.value);
const formatCurrency = (value: number) =>
`$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
</script>
<template>
<Card class="summary-card border-0 shadow-lg">
<template #content>
<div class="space-y-4 text-white">
<p class="text-xs font-semibold uppercase tracking-[0.2em] opacity-90">
Resumen de valoracion
</p>
<div class="flex items-center justify-between text-sm">
<span class="opacity-90">Valor de contenedor</span>
<strong class="text-xl">{{ formatCurrency(structure.containerValue) }}</strong>
</div>
<div class="flex items-center justify-between text-sm">
<span class="opacity-90">Valor de contenidos ({{ structure.contents.length }})</span>
<strong class="text-xl">{{ formatCurrency(contentsTotal) }}</strong>
</div>
<div class="border-t border-white/20 pt-4">
<p class="text-xs uppercase tracking-wide opacity-80">Valor total de estructura</p>
<p class="mt-1 text-4xl font-black leading-none">{{ formatCurrency(totalStructureValue) }}</p>
</div>
<div class="rounded-lg bg-white/12 p-3 text-xs leading-relaxed opacity-95">
El valor total se calcula sumando el activo base mas todos los elementos vinculados.
</div>
</div>
</template>
</Card>
</template>
<style scoped>
.summary-card {
background: linear-gradient(145deg, #1f7ae0 0%, #0b60c2 100%);
}
</style>

View File

@ -0,0 +1,124 @@
import type { FixedAssetStructure } from '../types/fixedAssetStructure';
const structuresMock: FixedAssetStructure[] = [
{
id: '1',
code: 'EST-0001',
name: 'Oficina Movil Mod-01',
containerCategory: 'Oficina Movil',
containerSerial: 'SER-CONT-2024-001',
location: 'Planta Norte - Sector B',
containerValue: 12500,
status: 'BORRADOR',
controlInfo: {
statusLabel: 'Borrador / En proceso',
createdAt: '24 de Mayo, 2024',
owner: 'Carlos Mendez'
},
contents: [
{
id: 'ACT-2001',
name: 'Laptop Dell XPS 15',
category: 'Equipos de Computo',
serial: 'DELL-8829-XP',
condition: 'EXCELENTE',
value: 26500
},
{
id: 'ACT-2002',
name: 'Silla Ergonomica Herman Miller',
category: 'Mobiliario de Oficina',
serial: 'MOB-HM-7721',
condition: 'EXCELENTE',
value: 11900
},
{
id: 'ACT-2003',
name: 'Escritorio Ajustable Pro',
category: 'Mobiliario de Oficina',
serial: 'MOB-DK-4410',
condition: 'BUENO',
value: 11000
}
]
},
{
id: '2',
code: 'EST-0002',
name: 'Kit Punto de Venta Sucursal Centro',
containerCategory: 'Punto de Venta',
containerSerial: 'POS-CN-001',
location: 'Sucursal Centro',
containerValue: 9800,
status: 'ACTIVA',
controlInfo: {
statusLabel: 'Aprobada / Activa',
createdAt: '12 de Enero, 2025',
owner: 'Valeria Ponce'
},
contents: [
{
id: 'ACT-3122',
name: 'Terminal POS Verifone',
category: 'Equipos POS',
serial: 'POS-VERI-8891',
condition: 'EXCELENTE',
value: 7300
},
{
id: 'ACT-3123',
name: 'Impresora Termica Epson',
category: 'Perifericos',
serial: 'EPS-TM-T20',
condition: 'BUENO',
value: 2650
}
]
},
{
id: '3',
code: 'EST-0003',
name: 'Cabina Tecnica Almacen A',
containerCategory: 'Infraestructura TI',
containerSerial: 'CB-TI-004',
location: 'Almacen General - Pasillo A',
containerValue: 22000,
status: 'EN_REVISION',
controlInfo: {
statusLabel: 'Revision Tecnica',
createdAt: '03 de Febrero, 2026',
owner: 'Ana Sofia Ruiz'
},
contents: [
{
id: 'ACT-4410',
name: 'Switch Cisco 48p',
category: 'Networking',
serial: 'CS-48-101',
condition: 'EXCELENTE',
value: 15300
},
{
id: 'ACT-4411',
name: 'UPS APC 3000VA',
category: 'Energia',
serial: 'UPS-APC-771',
condition: 'REGULAR',
value: 4800
}
]
}
];
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const fixedAssetStructuresService = {
async getStructures(): Promise<FixedAssetStructure[]> {
await wait(120);
return structuresMock;
},
async getStructureById(id: string): Promise<FixedAssetStructure | null> {
await wait(120);
return structuresMock.find((structure) => structure.id === id) ?? null;
}
};

View File

@ -0,0 +1,13 @@
export interface FixedAssetFormData {
name: string;
serial: string;
category: string;
brand: string;
model: string;
purchaseDate: string;
purchasePrice: number | null;
initialLocation: string;
initialStatus: string;
responsibleEmployee: string;
imageFileName: string;
}

View File

@ -0,0 +1,23 @@
export interface AssignmentAssetOption {
id: string;
code: string;
name: string;
serial: string;
category: string;
}
export interface AssignmentEmployeeOption {
id: string;
initials: string;
fullName: string;
role: string;
department: string;
}
export interface FixedAssetAssignmentFormData {
assetId: string;
employeeId: string;
deliveredAt: string;
condition: string;
notes: string;
}

View File

@ -0,0 +1,30 @@
export type StructureStatus = 'BORRADOR' | 'ACTIVA' | 'EN_REVISION' | 'INACTIVA';
export type ContentCondition = 'EXCELENTE' | 'BUENO' | 'REGULAR';
export interface StructureContentAsset {
id: string;
name: string;
category: string;
serial: string;
condition: ContentCondition;
value: number;
}
export interface StructureControlInfo {
statusLabel: string;
createdAt: string;
owner: string;
}
export interface FixedAssetStructure {
id: string;
code: string;
name: string;
containerCategory: string;
containerSerial: string;
location: string;
containerValue: number;
status: StructureStatus;
controlInfo: StructureControlInfo;
contents: StructureContentAsset[];
}

View File

@ -12,6 +12,14 @@ import Units from '../modules/catalog/components/units/Units.vue';
import ProductsIndex from '../modules/products/components/ProductsIndex.vue';
import ProductForm from '../modules/products/components/ProductForm.vue';
import StoresIndex from '../modules/stores/components/StoresIndex.vue';
import FixedAssetsIndex from '../modules/fixed-assets/components/FixedAssetsIndex.vue';
import FixedAssetForm from '../modules/fixed-assets/components/assets/FixedAssetForm.vue';
import FixedAssetAssignmentsIndex from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentsIndex.vue';
import FixedAssetAssignmentForm from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentForm.vue';
import FixedAssetAssignmentOffboardingForm from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentOffboardingForm.vue';
import FixedAssetStructuresIndex from '../modules/fixed-assets/components/structures/FixedAssetStructuresIndex.vue';
import FixedAssetStructureForm from '../modules/fixed-assets/components/structures/FixedAssetStructureForm.vue';
import FixedAssetStructureDetails from '../modules/fixed-assets/components/structures/FixedAssetStructureDetails.vue';
import RolesIndex from '../modules/users/components/RoleIndex.vue';
import RoleForm from '../modules/users/components/RoleForm.vue';
@ -230,6 +238,107 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true
}
},
{
path: 'fixed-assets',
name: 'FixedAssetsModule',
meta: {
title: 'Activos Fijos',
requiresAuth: true
},
children: [
{
path: '',
name: 'FixedAssets',
component: FixedAssetsIndex,
meta: {
title: 'Registro de Activos',
requiresAuth: true
}
},
{
path: 'create',
name: 'FixedAssetCreate',
component: FixedAssetForm,
meta: {
title: 'Registrar Activo Fijo',
requiresAuth: true
}
},
{
path: 'assignments',
name: 'FixedAssetAssignmentsModule',
meta: {
title: 'Asignaciones de Activos',
requiresAuth: true
},
children: [
{
path: '',
name: 'FixedAssetAssignments',
component: FixedAssetAssignmentsIndex,
meta: {
title: 'Listado de Asignaciones',
requiresAuth: true
}
},
{
path: 'create',
name: 'FixedAssetAssignmentCreate',
component: FixedAssetAssignmentForm,
meta: {
title: 'Asignacion de Activo a Empleado',
requiresAuth: true
}
},
{
path: ':id/offboarding',
name: 'FixedAssetAssignmentOffboarding',
component: FixedAssetAssignmentOffboardingForm,
meta: {
title: 'Baja de Asignacion de Activo',
requiresAuth: true
}
}
]
},
{
path: 'structures',
name: 'FixedAssetStructures',
component: FixedAssetStructuresIndex,
meta: {
title: 'Estructuras de Activos',
requiresAuth: true
}
},
{
path: 'structures/create',
name: 'FixedAssetStructuresCreate',
component: FixedAssetStructureForm,
meta: {
title: 'Crear Estructura de Activo',
requiresAuth: true
}
},
{
path: 'structures/:id/edit',
name: 'FixedAssetStructuresEdit',
component: FixedAssetStructureForm,
meta: {
title: 'Editar Estructura de Activo',
requiresAuth: true
}
},
{
path: 'structures/:id',
name: 'FixedAssetStructuresDetails',
component: FixedAssetStructureDetails,
meta: {
title: 'Detalle de Estructura de Activo',
requiresAuth: true
}
}
]
},
{
path: 'stores/:id',
name: 'StoreDetails',