feature/frontend-integration

This commit is contained in:
hreyes
2026-02-12 12:30:41 -06:00
parent 9d27066da1
commit eab10e6fbc
23 changed files with 1005 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import { Settings2 } from 'lucide-svelte';
import { tabSettings } from '$lib/config/csv-upload';
let {
activeTab,
settings = $bindable()
}: {
activeTab: string;
settings: Record<string, any>;
} = $props();
let currentFields = $derived(tabSettings[activeTab] || []);
// Determine grid columns based on number of fields
let gridClass = $derived(
currentFields.length > 4
? 'grid-cols-4'
: currentFields.length > 2
? 'grid-cols-3'
: currentFields.length > 1
? 'grid-cols-2'
: 'grid-cols-1'
);
</script>
<!--
No positioning here. Parent layout controls placement.
Just styling the "Island".
-->
<div
class="border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4 rounded-xl shadow-2xl mx-4 mb-4"
>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2 text-muted-foreground border-b pb-2">
<Settings2 class="h-4 w-4" />
<span class="text-xs font-semibold uppercase tracking-wider">Configuración: {activeTab}</span>
</div>
{#if currentFields.length > 0}
<div class="grid {gridClass} gap-6">
{#each currentFields as field}
<div class="flex flex-col gap-2">
{#if field.type !== 'boolean'}
<Label class="text-xs font-medium text-muted-foreground uppercase"
>{field.label}</Label
>
{/if}
{#if field.type === 'text'}
<Input type="text" bind:value={settings[field.name]} class="h-8" />
{:else if field.type === 'boolean'}
<div class="flex items-center space-x-2 h-8">
<Checkbox id={field.name} bind:checked={settings[field.name]} />
<Label
for={field.name}
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>{field.label}</Label
>
</div>
{:else if field.type === 'select' && field.options}
<select
class="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={settings[field.name]}
>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else if field.type === 'radio' && field.options}
<RadioGroup.Root
bind:value={settings[field.name]}
class="flex gap-4 h-8 items-center"
>
{#each field.options as opt}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={opt.value} id={`${field.name}-${opt.value}`} />
<Label for={`${field.name}-${opt.value}`}>{opt.label}</Label>
</div>
{/each}
</RadioGroup.Root>
{/if}
</div>
{/each}
</div>
{:else}
<div class="flex items-center justify-center h-8 text-sm text-muted-foreground italic">
No hay configuraciones específicas para este módulo.
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Upload, FileType } from 'lucide-svelte';
import type { CsvUploadItem } from '$lib/config/csv-upload';
let {
open = $bindable(false),
config,
currentSettings // Read-only prop passed from page
}: {
open: boolean;
config: CsvUploadItem;
currentSettings: Record<string, any>;
} = $props();
// State
let file: File | null = $state(null);
let isProcessing = $state(false);
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
file = target.files[0];
}
}
function handleProcess() {
isProcessing = true;
console.log('Processing Upload Request:', {
entityConfig: config,
file: file,
activeSettings: currentSettings // Log the global/tab settings being applied
});
// Mock processing time
setTimeout(() => {
isProcessing = false;
open = false;
alert(
`Procesando archivo para: ${config.title}\nConfiguración aplicada: ${JSON.stringify(currentSettings, null, 2)}`
);
}, 1000);
}
// Reset file state when config changes or modal opens
$effect(() => {
if (config) {
file = null;
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
{#if config.icon}
<config.icon class="h-5 w-5" />
{/if}
Importar {config.title}
</Dialog.Title>
<Dialog.Description>
Selecciona tu archivo CSV para procesar.
<br />
<span class="text-xs text-muted-foreground mt-1 block">
La configuración activa del pie de página se aplicará a esta carga.
</span>
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-6 py-4">
<!-- Stage 1: File Selection -->
<div class="flex flex-col gap-3">
<div
class="border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center gap-2 hover:bg-muted/50 transition-colors cursor-pointer relative"
>
<input
type="file"
accept=".csv,.txt"
class="absolute inset-0 opacity-0 cursor-pointer"
onchange={handleFileChange}
/>
{#if file}
<FileType class="h-12 w-12 text-primary" />
<div class="text-center">
<span class="font-medium text-sm block">{file.name}</span>
<span class="text-xs text-muted-foreground">{(file.size / 1024).toFixed(2)} KB</span>
</div>
{:else}
<Upload class="h-10 w-10 text-muted-foreground" />
<span class="font-medium text-sm">Arrastra tu archivo aquí o haz clic</span>
<span class="text-xs text-muted-foreground">Soporta CSV, TXT</span>
{/if}
</div>
</div>
<!-- Preview of Current Settings (Read Only) -->
{#if Object.keys(currentSettings).length > 0}
<div class="bg-muted/40 p-3 rounded text-xs text-muted-foreground">
<strong>Configuración Activa:</strong>
<ul class="list-disc pl-4 mt-1 space-y-0.5">
{#each Object.entries(currentSettings) as [key, value]}
<li>{key}: <span class="font-mono">{value}</span></li>
{/each}
</ul>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button onclick={handleProcess} disabled={!file || isProcessing}>
{#if isProcessing}
Procesando...
{:else}
Procesar
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,264 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import type { CsvUploadItem } from '$lib/config/csv-upload';
import { UploadCloud, Lock } from 'lucide-svelte';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
let {
items,
onUpload
}: {
items: CsvUploadItem[];
onUpload: (file: File, config: CsvUploadItem) => void;
} = $props();
let dragOverId = $state<string | null>(null);
// Group items
let groupedItems = $derived.by(() => {
const groups: Record<string, CsvUploadItem[]> = {};
const ungrouped: CsvUploadItem[] = [];
items.forEach((item) => {
if (item.group) {
if (!groups[item.group]) groups[item.group] = [];
groups[item.group].push(item);
} else {
ungrouped.push(item);
}
});
return { groups, ungrouped };
});
function handleDragEnter(e: DragEvent, id: string, disabled?: boolean) {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
dragOverId = id;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragOverId = null;
}
function handleDragOver(e: DragEvent, disabled?: boolean) {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
dragOverId = null;
}
function validateAndUpload(file: File, item: CsvUploadItem) {
const isValidExtension = file.name.toLowerCase().endsWith('.csv');
if (!isValidExtension) {
toast.error('Formato inválido. Solo se permiten archivos .csv');
return;
}
onUpload(file, item);
}
function handleDrop(e: DragEvent, item: CsvUploadItem) {
if (item.disabled) return;
e.preventDefault();
e.stopPropagation();
dragOverId = null;
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
validateAndUpload(e.dataTransfer.files[0], item);
}
}
function handleClick(id: string, disabled?: boolean) {
if (disabled) return;
const input = document.getElementById(`file-input-${id}`) as HTMLInputElement;
if (input) input.click();
}
function handleFileChange(e: Event, item: CsvUploadItem) {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
validateAndUpload(target.files[0], item);
target.value = '';
}
}
function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
if (item.disabled) {
e.preventDefault();
return;
}
if (!item.templateUrl) return;
e.preventDefault();
const link = document.createElement('a');
link.href = item.templateUrl;
link.download = item.templateUrl.split('/').pop() || 'plantilla.xls';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.info(`Descargando plantilla para ${item.title}...`);
}
</script>
<div class="flex flex-col gap-6 select-none">
{#if groupedItems.ungrouped.length > 0}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupedItems.ungrouped as item}
<div
class={cn(
'relative group transition-all duration-200 ease-in-out transform',
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
ondragleave={handleDragLeave}
ondragover={(e) => handleDragOver(e, item.disabled)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
roles="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
>
<input
type="file"
id={`file-input-${item.id}`}
class="hidden"
accept=".csv"
onchange={(e) => handleFileChange(e, item)}
disabled={item.disabled}
/>
<Card.Root
class={cn(
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && dragOverId === item.id
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
: ''
)}
>
{#if item.disabled}
<div class="absolute inset-0 bg-background/50 z-20 flex items-center justify-center">
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
</span>
</div>
{/if}
<Card.Content
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
>
{#if !item.disabled && dragOverId === item.id}
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
{:else}
<div
class={cn(
'p-3 bg-muted rounded-full transition-transform duration-200',
!item.disabled && 'group-hover:scale-110'
)}
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{/each}
</div>
{/if}
{#each Object.entries(groupedItems.groups) as [groupName, groupItems]}
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">
{groupName}
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupItems as item}
<div
class={cn(
'relative group transition-all duration-200 ease-in-out transform',
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
ondragleave={handleDragLeave}
ondragover={(e) => handleDragOver(e, item.disabled)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
role="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
>
<input
type="file"
id={`file-input-${item.id}`}
class="hidden"
accept=".csv"
onchange={(e) => handleFileChange(e, item)}
disabled={item.disabled}
/>
<Card.Root
class={cn(
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && dragOverId === item.id
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
: ''
)}
>
{#if item.disabled}
<div
class="absolute inset-0 bg-background/50 z-20 flex items-center justify-center"
>
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
</span>
</div>
{/if}
<Card.Content
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
>
{#if !item.disabled && dragOverId === item.id}
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
{:else}
<div
class={cn(
'p-3 bg-muted rounded-full transition-transform duration-200',
!item.disabled && 'group-hover:scale-110'
)}
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{/each}
</div>
</div>
{/each}
</div>

View File

@@ -14,6 +14,7 @@ import {
Shield,
Users,
Ship,
MoreHorizontal,
} from 'lucide-svelte';
import * as m from "$lib/paraglide/messages.js";
import { Title } from '../ui/alert';
@@ -423,6 +424,7 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: m["sidebar.clients_and_providers"](),
url: "/dashboard/clients_and_providers",

View File

@@ -1,4 +1,5 @@
<script lang="ts">
<script lang="ts">
import { tick } from "svelte";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
@@ -20,6 +21,17 @@
} = $props();
const sidebar = useSidebar();
let open = $state(false);
let position = $state({ x: 0, y: 0 });
async function handleMoreClick(e: MouseEvent) {
e.preventDefault();
open = false;
position = { x: e.clientX, y: e.clientY };
await tick();
open = true;
}
</script>
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
@@ -66,11 +78,28 @@
</DropdownMenu.Root>
</Sidebar.MenuItem>
{/each}
<Sidebar.MenuItem>
<Sidebar.MenuButton class="text-sidebar-foreground/70">
<Sidebar.MenuButton class="text-sidebar-foreground/70" onclick={handleMoreClick}>
<EllipsisIcon class="text-sidebar-foreground/70" />
<span>More</span>
</Sidebar.MenuButton>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger class="fixed z-50 size-0" style="top: {position.y}px; left: {position.x}px" />
<DropdownMenu.Content
class="w-48 rounded-lg"
side="right"
align="start"
>
<DropdownMenu.Item>
<a href="/dashboard/csv-upload" class="flex items-center gap-2 w-full">
<FolderIcon class="size-4 text-muted-foreground" />
<span>Carga CSV</span>
</a>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>