---
title: "Clover Founder Hub | Clover Founder Hub"
meta:
  description: "Clover is the platform that connects our portfolio of startups with the Clover team."
  "og:description": "Clover is the platform that connects our portfolio of startups with the Clover team."
  "og:title": "Clover Founder Hub"
---

**K**

# **SDR**

KŌGEL

```vue
**<script setup lang="ts">
import type {
  ApplicationDetail,
  JobApplicationStatus,
} from "~/types/application";
import { PIPELINE_STAGES } from "~/types/application";

const route = useRoute();
const router = useRouter();
const toast = useToast();

const jobId = route.params.id as string;
const applicationId = route.params.applicationId as string;

const {
  data: raw,
  pending,
  refresh,
} = useLazyFetch(\`/api/jobs/${jobId}/applications/${applicationId}\`);

const app = computed<ApplicationDetail | null>(
  () => raw.value as ApplicationDetail | null,
);
const jobInfo = computed(() => (raw.value as any)?.job ?? null);
const interviewCriteria = computed(
  () => jobInfo.value?.interview_criteria ?? null,
);

// ── Enrichment & scoring ──
const enrichingLinkedIn = ref(false);
const scoringInProgress = ref(false);

async function enrichLinkedIn() {
  if (!app.value?.id || !app.value.linkedin_url) return;
  enrichingLinkedIn.value = true;
  try {
    await $fetch(
      \`/api/jobs/${jobId}/applications/${app.value.id}/enrich-linkedin\`,
      { method: "POST" },
    );
    toast.add({ title: "LinkedIn profile enriched", color: "success" });
    refresh();
  } catch {
    toast.add({ title: "LinkedIn enrichment failed", color: "error" });
  } finally {
    enrichingLinkedIn.value = false;
  }
}

async function scoreApplication() {
  if (!app.value?.id) return;
  scoringInProgress.value = true;
  try {
    await $fetch(\`/api/jobs/${jobId}/applications/${app.value.id}/score\`, {
      method: "POST",
    });
    toast.add({ title: "AI scoring complete", color: "success" });
    refresh();
  } catch {
    toast.add({ title: "AI scoring failed", color: "error" });
  } finally {
    scoringInProgress.value = false;
  }
}

// ── Status management ──
const showRejectionDialog = ref(false);
const rejectionReason = ref("not_qualified");
const rejectionNote = ref("");
const rejectingApp = ref(false);

async function updateStatus(newStatus: string) {
  if (!app.value?.id) return;
  if (newStatus === "rejected") {
    showRejectionDialog.value = true;
    return;
  }
  try {
    await $fetch(\`/api/jobs/${jobId}/applications/${app.value.id}\`, {
      method: "PUT",
      body: { status: newStatus },
    });
    toast.add({ title: "Status updated", color: "success" });
    refresh();
  } catch {
    toast.add({ title: "Failed to update status", color: "error" });
  }
}

async function confirmRejection() {
  if (!app.value?.id) return;
  rejectingApp.value = true;
  try {
    await $fetch(\`/api/jobs/${jobId}/applications/${app.value.id}\`, {
      method: "PUT",
      body: {
        status: "rejected",
        rejection_reason: rejectionReason.value,
        rejection_note: rejectionNote.value || null,
      },
    });
    toast.add({ title: "Application rejected", color: "success" });
    showRejectionDialog.value = false;
    rejectionNote.value = "";
    refresh();
  } catch {
    toast.add({ title: "Failed to reject", color: "error" });
  } finally {
    rejectingApp.value = false;
  }
}

function advanceToNextStage() {
  if (!app.value) return;
  const currentIdx = PIPELINE_STAGES.indexOf(
    app.value.status as JobApplicationStatus,
  );
  if (currentIdx >= 0 && currentIdx < PIPELINE_STAGES.length - 1) {
    updateStatus(PIPELINE_STAGES[currentIdx + 1]!);
  }
}

// ── Composables ──
const emails = useApplicationEmails(jobId);
const interviewsState = useApplicationInterviews(jobId);
const activity = useApplicationActivity(jobId);

// ── Duplicates ──
const duplicates = ref<Array<{ id: string; job_listing: { title?: string } }>>(
  [],
);

async function fetchDuplicates() {
  if (!app.value?.id) return;
  try {
    duplicates.value = await $fetch<
      Array<{ id: string; job_listing: { title?: string } }>
    >(\`/api/jobs/${jobId}/applications/${app.value.id}/duplicates\`);
  } catch {
    /* silent */
  }
}

// ── LinkedIn data ──
const linkedinData = computed(() => app.value?.linkedin_data || null);
const linkedinJobs = computed(() => linkedinData.value?.jobs || []);
const linkedinEducations = computed(() => linkedinData.value?.educations || []);
const linkedinSkills = computed(() => linkedinData.value?.skills || []);
const linkedinLanguages = computed(() => linkedinData.value?.languages || []);

// ── Tabs ──
const activeTab = ref("profile");
const tabs = computed(() => {
  const items = [
    { label: "Profile", value: "profile", icon: "i-lucide-user" },
    { label: "Application", value: "application", icon: "i-lucide-file-text" },
  ];
  if (linkedinData.value) {
    items.splice(
      1,
      0,
      { label: "Experience", value: "experiences", icon: "i-lucide-briefcase" },
      {
        label: "Education",
        value: "education",
        icon: "i-lucide-graduation-cap",
      },
      { label: "Skills", value: "skills", icon: "i-lucide-puzzle" },
    );
  }
  if (app.value?.ai_score != null || app.value?.ai_status) {
    items.splice(1, 0, {
      label: "AI Score",
      value: "scoring",
      icon: "i-lucide-sparkles",
    });
  }
  items.push({
    label: "Interviews",
    value: "interviews",
    icon: "i-lucide-calendar-check",
  });
  items.push({ label: "Activity", value: "activity", icon: "i-lucide-clock" });
  if (app.value?.linkedin_url) {
    items.push({
      label: "LinkedIn",
      value: "linkedin",
      icon: "i-simple-icons-linkedin",
    });
  }
  return items;
});

// ── Watchers ──
watch(activeTab, (tab) => {
  if (!app.value?.id) return;
  if (tab === "activity") activity.fetchAll(app.value.id);
  if (tab === "interviews") interviewsState.fetchInterviews(app.value.id);
});

watch(
  app,
  (v) => {
    if (v?.id) fetchDuplicates();
  },
  { immediate: true },
);

// ── Copy to clipboard ──
async function copyToClipboard(text: string, label: string) {
  try {
    await navigator.clipboard.writeText(text);
    toast.add({ title: \`${label} copied\`, color: "success" });
  } catch {
    toast.add({ title: "Copy failed", color: "error" });
  }
}

// ── Mobile CV toggle ──
const showMobileDocuments = ref(false);
</script>

<template>
  <div>
    <!-- Loading skeleton -->
    <div v-if="pending" class="space-y-4 mx-auto">
      <div class="flex items-center gap-4">
        <USkeleton class="size-16 rounded-full" />
        <div class="space-y-2 flex-1">
          <USkeleton class="h-6 w-1/4" />
          <USkeleton class="h-4 w-1/3" />
        </div>
      </div>
      <USkeleton class="h-10 w-full" />
      <div class="grid grid-cols-3 gap-6">
        <USkeleton class="h-64 col-span-2" />
        <USkeleton class="h-64" />
      </div>
    </div>

    <!-- Content -->
    <div v-else-if="app" class="space-y-4">
      <!-- Mobile documents panel -->
      <div
        v-if="showMobileDocuments"
        class="lg:hidden border-b border-default h-[50vh]"
      >
        <ApplicationDetailDocumentsPanel :app="app" />
      </div>

      <!-- Main content: 2 column layout -->

      <!-- Tabs -->

      <div cmlass="space-y-4">
        <ApplicationDetailEducationTab
          :linkedin-data="linkedinData"
          :linkedin-educations="linkedinEducations"
        />

        <ApplicationDetailSkillsTab
          v-if="activeTab === 'skills'"
          :linkedin-skills="linkedinSkills"
          :linkedin-languages="linkedinLanguages"
        />

        <ApplicationDetailApplicationTab
          v-if="activeTab === 'application'"
          :app="app"
          :job-id="jobId"
          :enriching-linked-in="enrichingLinkedIn"
          @notes-saved="() => refresh()"
          @enrich-linkedin="enrichLinkedIn"
        />

        <ApplicationDetailInterviewsTab
          v-if="activeTab === 'interviews'"
          :app="app"
          :job-id="jobId"
          :interviews="interviewsState.interviews.value"
          :loading-interviews="interviewsState.loadingInterviews.value"
          :show-schedule-form="interviewsState.showScheduleForm.value"
          :show-scorecard-form="interviewsState.showScorecardForm.value"
          :editing-scorecard="interviewsState.editingScorecard.value"
          :reschedule-interview-id="interviewsState.rescheduleInterviewId.value"
          :generating-prep="interviewsState.generatingPrep.value"
          :generating-summary="interviewsState.generatingSummary.value"
          :schedule-form="interviewsState.scheduleForm"
          :scorecard-form="interviewsState.scorecardForm"
          :interview-decision-summary="
            interviewsState.interviewDecisionSummary.value
          "
          :has-own-scorecard="interviewsState.hasOwnScorecard"
          @update:show-schedule-form="
            interviewsState.showScheduleForm.value = $event
          "
          @update:show-scorecard-form="
            interviewsState.showScorecardForm.value = $event
          "
          @schedule="
            interviewsState.scheduleInterview(app!.id).then((ok) => {
              if (ok) refresh();
            })
          "
          @reschedule="interviewsState.rescheduleInterview(app!.id)"
          @update-status="
            (id: string, status: string) =>
              interviewsState.updateInterviewStatus(app!.id, id, status)
          "
          @open-scorecard="
            (id: string) =>
              interviewsState.openScorecardForm(
                id,
                interviewCriteria?.length ? interviewCriteria : undefined,
              )
          "
          @open-reschedule="interviewsState.openRescheduleForm"
          @submit-scorecard="interviewsState.submitScorecard(app!.id)"
          @send-invite="
            (i: any) => interviewsState.sendInterviewInvite(app!.id, i)
          "
          @generate-prep="
            (id: string) => interviewsState.generateAiPrep(app!.id, id)
          "
          @generate-summary="
            (id: string) => interviewsState.generateAiSummary(app!.id, id)
          "
        />

        <ApplicationDetailActivityTab
          v-if="activeTab === 'activity'"
          :activity-events="activity.activityEvents.value"
          :loading-events="activity.loadingEvents.value"
          :comments="activity.comments.value"
          :loading-comments="activity.loadingComments.value"
          :new-comment="activity.newComment.value"
          :posting-comment="activity.postingComment.value"
          :editing-comment-id="activity.editingCommentId.value"
          :editing-comment-content="activity.editingCommentContent.value"
          :deleting-comment-id="activity.deletingCommentId.value"
          @update:new-comment="activity.newComment.value = $event"
          @update:editing-comment-content="
            activity.editingCommentContent.value = $event
          "
          @post-comment="activity.postComment(app!.id)"
          @start-edit="activity.startEditComment"
          @cancel-edit="activity.cancelEditComment"
          @save-edit="activity.saveEditComment(app!.id)"
          @delete-comment="(id: string) => activity.deleteComment(app!.id, id)"
        />

        <ApplicationDetailLinkedInTab
          v-if="activeTab === 'linkedin'"
          :linkedin-data="linkedinData"
          :linkedin-url="app.linkedin_url"
          :enriching-linked-in="enrichingLinkedIn"
          @enrich-linkedin="enrichLinkedIn"
        />
      </div>
    </div>

    <!-- Documents column (desktop) -->

    <!-- Not found -->
    <div v-else class="flex flex-col items-center justify-center py-24">
      <UIcon name="i-lucide-user-x" class="size-16 text-muted mb-4" />
      <h2 class="text-lg font-semibold mb-2">Application not found</h2>
      <UButton variant="soft" @click="router.push(\`/admin/jobs/${jobId}\`)">
        Back to job
      </UButton>
    </div>

    <!-- Rejection Dialog -->
    <UModal
      :open="showRejectionDialog"
      @update:open="showRejectionDialog = $event"
    >
      <template #header>
        <h3 class="font-semibold">Reject Application</h3>
      </template>
      <template #body>
        <div class="space-y-4">
          <p class="text-sm text-muted">
            Please select a reason for rejecting
            <strong>{{ app?.applicant_name }}</strong
            >.
          </p>
          <UFormField label="Reason">
            <USelectMenu
              v-model="rejectionReason"
              :items="REJECTION_REASONS"
              value-key="value"
              class="w-full"
            />
          </UFormField>
          <UFormField label="Additional note (optional)">
            <UTextarea
              v-model="rejectionNote"
              :rows="3"
              placeholder="Add context about the rejection..."
            />
          </UFormField>
        </div>
      </template>
      <template #footer>
        <div class="flex justify-end gap-2 w-full">
          <UButton
            variant="ghost"
            color="neutral"
            @click="showRejectionDialog = false"
            >Cancel</UButton
          >
          <UButton
            color="error"
            :loading="rejectingApp"
            @click="confirmRejection"
            >Reject</UButton
          >
        </div>
      </template>
    </UModal>

    <!-- Email Dialog -->
    <UModal
      :open="emails.showEmailDialog.value"
      @update:open="emails.showEmailDialog.value = $event"
    >
      <template #header>
        <h3 class="font-semibold">Send Email to {{ app?.applicant_name }}</h3>
      </template>
      <template #body>
        <div class="space-y-4">
          <UFormField label="Template">
            <USelectMenu
              :model-value="emails.emailTemplate.value"
              :items="emails.EMAIL_TEMPLATES"
              value-key="value"
              class="w-full"
              @update:model-value="emails.selectTemplate"
            />
          </UFormField>
          <UFormField label="Subject">
            <UInput
              v-model="emails.emailSubject.value"
              placeholder="Email subject..."
              class="w-full"
            />
          </UFormField>
          <UFormField label="Body">
            <UTextarea
              v-model="emails.emailBody.value"
              :rows="6"
              placeholder="Email content..."
              autoresize
              class="w-full"
            />
          </UFormField>
          <p class="text-xs text-muted">
            Email will be sent from hello@hub.cloverfund.vc to
            {{ app?.applicant_email }}
          </p>
        </div>
      </template>
      <template #footer>
        <div class="flex justify-end gap-2 w-full">
          <UButton
            variant="ghost"
            color="neutral"
            @click="emails.showEmailDialog.value = false"
            >Cancel</UButton
          >
          <UButton
            :loading="emails.sendingEmail.value"
            icon="i-lucide-send"
            @click="
              emails.sendEmail(app!.id, () => activity.fetchEvents(app!.id))
            "
          >
            Send Email
          </UButton>
        </div>
      </template>
    </UModal>
  </div>
</template>
**
```Location**Remote**Type**Full-time**Experience**any**Category**legal**Salary**From 80 000€**Published**April 22, 2026**