Update Person grid view vue and reverted import v2 back to v1 (v2 not production ready)

This commit is contained in:
Simon Pocrnjič 2026-01-15 20:38:08 +01:00
parent 357a254e82
commit 091fb07646
6 changed files with 438 additions and 219 deletions

View File

@ -184,7 +184,7 @@ public function store(Request $request)
} }
// Kick off processing of an import - simple synchronous step for now // Kick off processing of an import - simple synchronous step for now
public function process(Import $import, Request $request, ImportServiceV2 $processor) public function process(Import $import, Request $request, ImportProcessor $processor)
{ {
$import->update(['status' => 'validating', 'started_at' => now()]); $import->update(['status' => 'validating', 'started_at' => now()]);

View File

@ -103,7 +103,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
$payload = $this->buildPayloadForAddress($address); $payload = $this->buildPayloadForAddress($address);
$payload['person_id'] = $personId; $payload['person_id'] = $personId;
$addressEntity = new \App\Models\Person\PersonAddress; $addressEntity = new PersonAddress;
$addressEntity->fill($payload); $addressEntity->fill($payload);
$addressEntity->save(); $addressEntity->save();
@ -129,7 +129,7 @@ public function process(Import $import, array $mapped, array $raw, array $contex
protected function resolveAddress(string $address, int $personId): mixed protected function resolveAddress(string $address, int $personId): mixed
{ {
return \App\Models\Person\PersonAddress::where('person_id', $personId) return PersonAddress::where('person_id', $personId)
->where('address', $address) ->where('address', $address)
->first(); ->first();
} }

View File

@ -384,6 +384,7 @@ const switchToTab = (tab) => {
</TabsList> </TabsList>
<TabsContent value="person" class="py-2"> <TabsContent value="person" class="py-2">
<PersonInfoPersonTab <PersonInfoPersonTab
:is-client-case="clientCaseUuid ? true : false"
:person="person" :person="person"
:edit="edit" :edit="edit"
:person-edit="personEdit" :person-edit="personEdit"

View File

@ -1,14 +1,16 @@
<script setup> <script setup>
import { UserEditIcon } from "@/Utilities/Icons"; import { UserEditIcon } from "@/Utilities/Icons";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { fmtDateDMY } from "@/Utilities/functions";
const props = defineProps({ const props = defineProps({
person: Object, person: Object,
isClientCase: { type: Boolean, default: false },
edit: { type: Boolean, default: true }, edit: { type: Boolean, default: true },
personEdit: { type: Boolean, default: true }, personEdit: { type: Boolean, default: true },
}); });
const emit = defineEmits(['edit']); const emit = defineEmits(["edit"]);
const getMainAddress = (adresses) => { const getMainAddress = (adresses) => {
const addr = adresses.filter((a) => a.type.id === 1)[0] ?? ""; const addr = adresses.filter((a) => a.type.id === 1)[0] ?? "";
@ -30,7 +32,7 @@ const getMainPhone = (phones) => {
}; };
const handleEdit = () => { const handleEdit = () => {
emit('edit'); emit("edit");
}; };
</script> </script>
@ -44,51 +46,126 @@ const handleEdit = () => {
> >
<UserEditIcon size="md" /> <UserEditIcon size="md" />
<span>Uredi</span> <span>Uredi</span>
</button> </Button>
</div> </div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3"> <div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Nu.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Primer ref.
</p>
<p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p> <p class="text-sm font-semibold text-gray-900">{{ person.nu }}</p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Name.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Naziv</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900">
{{ person.full_name }} {{ person.full_name }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Tax NU.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Davčna
</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900">
{{ person.tax_number }} {{ person.tax_number }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Social security NU.</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Emšo</p>
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900">
{{ person.social_security_number }} {{ person.social_security_number }}
</p> </p>
</div> </div>
</div> </div>
<div class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"> <div
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> v-if="isClientCase"
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Address</p> class="grid grid-rows-* grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-3"
>
<div
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Naslov
</p>
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }} {{ getMainAddress(person.addresses) }}
</p> </p>
</div> </div>
<div class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Phone</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Telefon
</p>
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }} {{ getMainPhone(person.phones) }}
</p> </p>
</div> </div>
<div class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Description</p> class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Dat. rojstva
</p>
<p class="text-sm font-medium text-gray-900">
{{ fmtDateDMY(person.birthday) }}
</p>
</div>
</div>
<div v-else class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Naslov
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainAddress(person.addresses) }}
</p>
</div>
<div
class="rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Telefon
</p>
<p class="text-sm font-medium text-gray-900">
{{ getMainPhone(person.phones) }}
</p>
</div>
</div>
<div
class="grid grid-rows-* grid-cols-1 md:grid-cols-2 gap-3 mt-3"
:class="[isClientCase ? 'md:grid-cols-2' : '']"
>
<div
v-if="isClientCase"
class="md:col-span-full lg:col-span-1 rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">
Delodajalec
</p>
<p class="text-sm font-medium text-gray-900">
{{ person.employer }}
</p>
</div>
<div
class="md:col-span-full rounded-lg p-3 bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow"
:class="[isClientCase ? 'lg:col-span-1' : '']"
>
<p class="text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Opis</p>
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
{{ person.description }} {{ person.description }}
</p> </p>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,182 +1,205 @@
<script setup> <script setup>
import UpdateDialog from '@/Components/Dialogs/UpdateDialog.vue'; import UpdateDialog from "@/Components/Dialogs/UpdateDialog.vue";
import SectionTitle from '@/Components/SectionTitle.vue'; import SectionTitle from "@/Components/SectionTitle.vue";
import { useForm, Field as FormField } from "vee-validate"; import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod"; import * as z from "zod";
import { router } from '@inertiajs/vue3'; import { router } from "@inertiajs/vue3";
import { ref } from 'vue'; import { ref } from "vue";
import { import { FormControl, FormItem, FormLabel, FormMessage } from "@/Components/ui/form";
FormControl,
FormItem,
FormLabel,
FormMessage,
} from "@/Components/ui/form";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea"; import { Textarea } from "@/Components/ui/textarea";
import DatePicker from "../DatePicker.vue";
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
person: Object person: Object,
}); });
const processingUpdate = ref(false); const processingUpdate = ref(false);
const emit = defineEmits(['close']); const emit = defineEmits(["close"]);
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
z.object({ z.object({
full_name: z.string().min(1, "Naziv je obvezen."), full_name: z.string().min(1, "Naziv je obvezen."),
tax_number: z.string().optional(), tax_number: z.string().optional(),
social_security_number: z.string().optional(), social_security_number: z.string().optional(),
birthday: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
employer: z.string().optional(),
}) })
); );
const form = useForm({ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema,
initialValues: { initialValues: {
full_name: props.person?.full_name || '', full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || '', tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || '', social_security_number: props.person?.social_security_number || "",
description: props.person?.description || '' birthday: props.person?.birthday || "",
description: props.person?.description || "",
employer: props.person?.employer || "",
}, },
}); });
const close = () => { const close = () => {
emit('close'); emit("close");
setTimeout(() => { setTimeout(() => {
form.resetForm({ form.resetForm({
values: { values: {
full_name: props.person?.full_name || '', full_name: props.person?.full_name || "",
tax_number: props.person?.tax_number || '', tax_number: props.person?.tax_number || "",
social_security_number: props.person?.social_security_number || '', social_security_number: props.person?.social_security_number || "",
description: props.person?.description || '' birthday: props.person?.birthday || "",
} description: props.person?.description || "",
}); employer: props.person?.employer || "",
}, 500); },
} });
}, 500);
};
const updatePerson = async () => { const updatePerson = async () => {
processingUpdate.value = true; processingUpdate.value = true;
const { values } = form; const { values } = form;
router.put( router.put(route("person.update", props.person), values, {
route('person.update', props.person), preserveScroll: true,
values, onSuccess: () => {
{ processingUpdate.value = false;
preserveScroll: true, close();
onSuccess: () => { },
processingUpdate.value = false; onError: (errors) => {
close(); // Map Inertia errors to VeeValidate field errors
}, Object.keys(errors).forEach((field) => {
onError: (errors) => { const errorMessages = Array.isArray(errors[field])
// Map Inertia errors to VeeValidate field errors ? errors[field]
Object.keys(errors).forEach((field) => { : [errors[field]];
const errorMessages = Array.isArray(errors[field]) form.setFieldError(field, errorMessages[0]);
? errors[field] });
: [errors[field]]; processingUpdate.value = false;
form.setFieldError(field, errorMessages[0]); },
}); onFinish: () => {
processingUpdate.value = false; processingUpdate.value = false;
}, },
onFinish: () => { });
processingUpdate.value = false; };
},
}
);
}
const onSubmit = form.handleSubmit(() => { const onSubmit = form.handleSubmit(() => {
updatePerson(); updatePerson();
}); });
const onConfirm = () => { const onConfirm = () => {
onSubmit(); onSubmit();
} };
</script> </script>
<template> <template>
<UpdateDialog <UpdateDialog
:show="show" :show="show"
:title="`Posodobi ${person.full_name}`" :title="`Posodobi ${person.full_name}`"
confirm-text="Shrani" confirm-text="Shrani"
:processing="processingUpdate" :processing="processingUpdate"
@close="close" @close="close"
@confirm="onConfirm" @confirm="onConfirm"
> >
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<SectionTitle class="border-b mb-4"> <SectionTitle class="border-b mb-4">
<template #title> <template #title> Oseba </template>
Oseba </SectionTitle>
</template>
</SectionTitle>
<div class="space-y-4"> <div class="space-y-4">
<FormField v-slot="{ componentField }" name="full_name"> <FormField v-slot="{ componentField }" name="full_name">
<FormItem> <FormItem>
<FormLabel>Naziv</FormLabel> <FormLabel>Naziv</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="cfullname" id="cfullname"
type="text" type="text"
placeholder="Naziv" placeholder="Naziv"
autocomplete="full-name" autocomplete="full-name"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="tax_number"> <FormField v-slot="{ componentField }" name="tax_number">
<FormItem> <FormItem>
<FormLabel>Davčna</FormLabel> <FormLabel>Davčna</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="ctaxnumber" id="ctaxnumber"
type="text" type="text"
placeholder="Davčna številka" placeholder="Davčna številka"
autocomplete="tax-number" autocomplete="tax-number"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="social_security_number"> <FormField v-slot="{ componentField }" name="social_security_number">
<FormItem> <FormItem>
<FormLabel>Matična / Emšo</FormLabel> <FormLabel>Matična / Emšo</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="csocialSecurityNumber" id="csocialSecurityNumber"
type="text" type="text"
placeholder="Matična / Emšo" placeholder="Matična / Emšo"
autocomplete="social-security-number" autocomplete="social-security-number"
v-bind="componentField" v-bind="componentField"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<FormField v-slot="{ componentField }" name="description"> <FormField v-slot="{ componentField }" name="employer">
<FormItem> <FormItem>
<FormLabel>Opis</FormLabel> <FormLabel>Delodajalec</FormLabel>
<FormControl> <FormControl>
<Textarea <Input
id="cdescription" id="cemployer"
placeholder="Opis" type="text"
v-bind="componentField" placeholder="Delodajalec"
/> autocomplete="employer"
</FormControl> v-bind="componentField"
<FormMessage /> />
</FormItem> </FormControl>
</FormField> <FormMessage />
</div> </FormItem>
</form> </FormField>
</UpdateDialog>
<FormField v-slot="{ value, handleChange }" name="birthday">
<FormItem>
<FormLabel>Datum rojstva</FormLabel>
<FormControl>
<DatePicker
id="cbirthday"
:model-value="value"
@update:model-value="handleChange"
format="dd.MM.yyyy"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Opis</FormLabel>
<FormControl>
<Textarea id="cdescription" placeholder="Opis" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</UpdateDialog>
</template> </template>

View File

@ -2,9 +2,12 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge"; import { Badge } from "@/Components/ui/badge";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import { ChevronRightIcon } from "@heroicons/vue/24/outline";
import { computed, ref } from "vue";
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
limit: Number, limit: Number,
@ -14,72 +17,187 @@ const props = defineProps({
truncated: Boolean, truncated: Boolean,
hasHeader: Boolean, hasHeader: Boolean,
}) })
const emits = defineEmits(['close','change-limit','refresh']) const emits = defineEmits(['close','change-limit','refresh'])
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
// State
const selectedRow = ref(null);
const hideEmptyRows = ref(true);
// Filter out columns with empty headers
const visibleColumns = computed(() => {
if (!props.columns) return [];
return props.columns.filter(col => col && String(col).trim() !== '');
});
// Check if row is empty (first 2 columns are empty)
function isRowEmpty(row) {
if (!visibleColumns.value || visibleColumns.value.length === 0) return false;
const firstCols = visibleColumns.value.slice(0, 2);
return firstCols.every(col => !row[col] || String(row[col]).trim() === '');
}
// Filtered rows
const visibleRows = computed(() => {
if (!props.rows) return [];
let filtered = props.rows;
if (hideEmptyRows.value) {
filtered = filtered.filter(r => !isRowEmpty(r));
}
return filtered.map((r, idx) => ({ ...r, index: idx + 1 }));
});
// Select row
function selectRow(row) {
selectedRow.value = row;
}
function onLimit(val) {
emits('change-limit', Number(val));
emits('refresh');
}
</script> </script>
<template> <template>
<Dialog :open="show" @update:open="(val) => !val && $emit('close')"> <Dialog :open="show" @update:open="(val) => !val && $emit('close')">
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent class="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col p-0">
<DialogHeader> <!-- Header -->
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle> <div class="px-6 py-4 border-b bg-linear-to-r from-gray-50 to-white">
</DialogHeader> <div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3 pb-3 border-b"> <h2 class="text-xl font-semibold text-gray-900">CSV Preview</h2>
<div class="flex items-center gap-2"> <p class="text-sm text-gray-500 mt-1">
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label> Showing {{ visibleRows.length }} of {{ rows.length }} rows
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }"> </p>
<SelectTrigger id="limit-select" class="w-24 h-8"> </div>
<SelectValue /> <div class="flex items-center gap-3">
</SelectTrigger> <div class="flex items-center gap-2">
<SelectContent> <Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
<SelectItem value="50">50</SelectItem> <Select :model-value="String(limit)" @update:model-value="onLimit">
<SelectItem value="100">100</SelectItem> <SelectTrigger id="limit-select" class="w-24 h-8">
<SelectItem value="200">200</SelectItem> <SelectValue />
<SelectItem value="300">300</SelectItem> </SelectTrigger>
<SelectItem value="500">500</SelectItem> <SelectContent>
</SelectContent> <SelectItem value="50">50</SelectItem>
</Select> <SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="300">300</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
</div>
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</Button>
<div class="flex items-center gap-2">
<Checkbox
id="hide-empty-rows"
:checked="hideEmptyRows"
@update:checked="(val) => hideEmptyRows = val"
/>
<Label for="hide-empty-rows" class="text-xs cursor-pointer">
Hide empty rows
</Label>
</div>
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
Truncated at limit
</Badge>
</div>
</div> </div>
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</Button>
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
Truncated at limit
</Badge>
</div> </div>
<div class="flex-1 overflow-auto border rounded-lg"> <!-- Split View -->
<Table> <div class="flex-1 flex overflow-hidden">
<TableHeader class="sticky top-0 bg-white z-10"> <!-- Left Panel - Row List -->
<TableRow> <div class="w-96 border-r bg-gray-50 overflow-y-auto">
<TableHead class="w-16">#</TableHead> <div v-if="loading" class="p-8 text-center text-gray-500">
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
</TableRow> Loading...
</TableHeader> </div>
<TableBody> <div v-else-if="!visibleRows.length" class="p-8 text-center text-gray-500">
<TableRow v-if="loading"> No rows to display
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500"> </div>
Loading <div v-else class="divide-y">
</TableCell> <button
</TableRow> v-for="row in visibleRows"
<TableRow v-for="(r, idx) in rows" :key="idx"> :key="row.index"
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell> @click="selectRow(row)"
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap"> class="w-full px-4 py-3 text-left hover:bg-white transition-colors"
{{ r[col] }} :class="{
</TableCell> 'bg-white shadow-sm': selectedRow?.index === row.index,
</TableRow> }"
<TableRow v-if="!loading && !rows.length"> >
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500"> <div class="flex items-center justify-between gap-3">
No rows <div class="flex items-center gap-3 flex-1 min-w-0">
</TableCell> <!-- Row Number -->
</TableRow> <div class="flex-shrink-0">
</TableBody> <div class="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-semibold">
</Table> {{ row.index }}
</div>
</div>
<!-- Row Preview -->
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold text-gray-900 mb-1">
Row #{{ row.index }}
</div>
<div class="text-xs text-gray-600 truncate">
{{
visibleColumns.slice(0, 2).map(col => row[col]).filter(Boolean).join(' • ') || 'Empty row'
}}
</div>
</div>
</div>
<!-- Arrow -->
<ChevronRightIcon class="h-4 w-4 text-gray-400 flex-shrink-0" />
</div>
</button>
</div>
</div>
<!-- Right Panel - Row Details -->
<div v-if="selectedRow" class="flex-1 overflow-y-auto p-6">
<!-- Row Header -->
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900">
Row #{{ selectedRow.index }}
</h3>
<p class="text-sm text-gray-500">Full row details</p>
</div>
<!-- Row Data -->
<div class="bg-gray-50 rounded-lg p-4">
<dl class="grid grid-cols-1 gap-3">
<div
v-for="col in visibleColumns"
:key="col"
class="flex items-start gap-3 py-2 border-b border-gray-200 last:border-0"
>
<dt class="text-sm font-medium text-gray-600 w-48 flex-shrink-0">
{{ col }}
</dt>
<dd class="text-sm text-gray-900 flex-1 font-medium whitespace-pre-wrap break-words">
{{ selectedRow[col] || '—' }}
</dd>
</div>
</dl>
</div>
</div>
<!-- Empty State for Right Panel -->
<div v-else class="flex-1 flex items-center justify-center text-gray-400">
<div class="text-center">
<div class="text-5xl mb-3">📄</div>
<p class="text-sm">Select a row to view details</p>
</div>
</div>
</div> </div>
<div class="text-xs text-gray-500 pt-3 border-t"> <!-- Footer -->
Showing up to {{ limit }} rows from source file. <div class="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500">
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span> Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
Click a row to view full details
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>