Add job application tracker (admin-only)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Full CRUD GraphQL API for tracking job applications with status workflow. Frontend component in CV view, hidden from print. Login now redirects to intended route after auth via query param. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,3 +57,9 @@ models:
|
|||||||
fields:
|
fields:
|
||||||
deletedAt:
|
deletedAt:
|
||||||
resolver: false
|
resolver: false
|
||||||
|
JobApplication:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.JobApplication
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
22
backend/graph/job_application.resolvers.go
Normal file
22
backend/graph/job_application.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *jobApplicationResolver) ID(ctx context.Context, obj *models.JobApplication) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobApplication returns JobApplicationResolver implementation.
|
||||||
|
func (r *Resolver) JobApplication() JobApplicationResolver { return &jobApplicationResolver{r} }
|
||||||
|
|
||||||
|
type jobApplicationResolver struct{ *Resolver }
|
||||||
@@ -24,6 +24,16 @@ type CreateFavoriteInput struct {
|
|||||||
Link *string `json:"link,omitempty"`
|
Link *string `json:"link,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateJobApplicationInput struct {
|
||||||
|
JobTitle string `json:"jobTitle"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Location *string `json:"location,omitempty"`
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Notes *string `json:"notes,omitempty"`
|
||||||
|
AppliedAt *time.Time `json:"appliedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreatePostInput struct {
|
type CreatePostInput struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@@ -96,6 +106,16 @@ type SteamStatus struct {
|
|||||||
RecentGames []*SteamGame `json:"recentGames"`
|
RecentGames []*SteamGame `json:"recentGames"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateJobApplicationInput struct {
|
||||||
|
JobTitle *string `json:"jobTitle,omitempty"`
|
||||||
|
Company *string `json:"company,omitempty"`
|
||||||
|
Location *string `json:"location,omitempty"`
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
Notes *string `json:"notes,omitempty"`
|
||||||
|
AppliedAt *time.Time `json:"appliedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type UpdatePostInput struct {
|
type UpdatePostInput struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|||||||
@@ -293,6 +293,73 @@ func (r *mutationResolver) CreateActivity(ctx context.Context, input model.Creat
|
|||||||
return &activity, nil
|
return &activity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateJobApplication is the resolver for the createJobApplication field.
|
||||||
|
func (r *mutationResolver) CreateJobApplication(ctx context.Context, input model.CreateJobApplicationInput) (*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
app := models.JobApplication{
|
||||||
|
JobTitle: input.JobTitle,
|
||||||
|
Company: input.Company,
|
||||||
|
Location: input.Location,
|
||||||
|
URL: input.URL,
|
||||||
|
Status: input.Status,
|
||||||
|
Notes: input.Notes,
|
||||||
|
AppliedAt: input.AppliedAt,
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Create(&app).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobApplication is the resolver for the updateJobApplication field.
|
||||||
|
func (r *mutationResolver) UpdateJobApplication(ctx context.Context, id int, input model.UpdateJobApplicationInput) (*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var app models.JobApplication
|
||||||
|
if err := r.Store.DB.First(&app, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.JobTitle != nil {
|
||||||
|
app.JobTitle = *input.JobTitle
|
||||||
|
}
|
||||||
|
if input.Company != nil {
|
||||||
|
app.Company = *input.Company
|
||||||
|
}
|
||||||
|
if input.Location != nil {
|
||||||
|
app.Location = input.Location
|
||||||
|
}
|
||||||
|
if input.URL != nil {
|
||||||
|
app.URL = input.URL
|
||||||
|
}
|
||||||
|
if input.Status != nil {
|
||||||
|
app.Status = *input.Status
|
||||||
|
}
|
||||||
|
if input.Notes != nil {
|
||||||
|
app.Notes = input.Notes
|
||||||
|
}
|
||||||
|
if input.AppliedAt != nil {
|
||||||
|
app.AppliedAt = input.AppliedAt
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Save(&app).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteJobApplication is the resolver for the deleteJobApplication field.
|
||||||
|
func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bool, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return false, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Delete(&models.JobApplication{}, id).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Users is the resolver for the users field.
|
// Users is the resolver for the users field.
|
||||||
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
||||||
var users []models.User
|
var users []models.User
|
||||||
@@ -504,6 +571,30 @@ func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobApplications is the resolver for the jobApplications field.
|
||||||
|
func (r *queryResolver) JobApplications(ctx context.Context) ([]*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var apps []*models.JobApplication
|
||||||
|
if err := r.Store.DB.Order("created_at desc").Find(&apps).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobApplication is the resolver for the jobApplication field.
|
||||||
|
func (r *queryResolver) JobApplication(ctx context.Context, id int) (*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var app models.JobApplication
|
||||||
|
if err := r.Store.DB.First(&app, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Mutation returns MutationResolver implementation.
|
// Mutation returns MutationResolver implementation.
|
||||||
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
|
|||||||
32
backend/graph/schema/job_application.graphql
Normal file
32
backend/graph/schema/job_application.graphql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
type JobApplication {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
jobTitle: String!
|
||||||
|
company: String!
|
||||||
|
location: String
|
||||||
|
url: String
|
||||||
|
status: String!
|
||||||
|
notes: String
|
||||||
|
appliedAt: Time
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateJobApplicationInput {
|
||||||
|
jobTitle: String!
|
||||||
|
company: String!
|
||||||
|
location: String
|
||||||
|
url: String
|
||||||
|
status: String!
|
||||||
|
notes: String
|
||||||
|
appliedAt: Time
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateJobApplicationInput {
|
||||||
|
jobTitle: String
|
||||||
|
company: String
|
||||||
|
location: String
|
||||||
|
url: String
|
||||||
|
status: String
|
||||||
|
notes: String
|
||||||
|
appliedAt: Time
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ type Query {
|
|||||||
giteaFeed: GiteaFeedItem
|
giteaFeed: GiteaFeedItem
|
||||||
steamStatus: SteamStatus
|
steamStatus: SteamStatus
|
||||||
me: User
|
me: User
|
||||||
|
jobApplications: [JobApplication!]!
|
||||||
|
jobApplication(id: ID!): JobApplication
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@@ -28,4 +30,7 @@ type Mutation {
|
|||||||
setUserAdmin(id: ID!, admin: Boolean!): User!
|
setUserAdmin(id: ID!, admin: Boolean!): User!
|
||||||
createFavorite(input: CreateFavoriteInput!): Favorite!
|
createFavorite(input: CreateFavoriteInput!): Favorite!
|
||||||
createActivity(input: CreateActivityInput!): Activity!
|
createActivity(input: CreateActivityInput!): Activity!
|
||||||
|
createJobApplication(input: CreateJobApplicationInput!): JobApplication!
|
||||||
|
updateJobApplication(id: ID!, input: UpdateJobApplicationInput!): JobApplication!
|
||||||
|
deleteJobApplication(id: ID!): Boolean!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,3 +66,17 @@ type Rowing struct {
|
|||||||
TimePer500m float64 `json:"timePer500m"`
|
TimePer500m float64 `json:"timePer500m"`
|
||||||
Calories float64 `json:"calories"`
|
Calories float64 `json:"calories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JobApplication struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
|
JobTitle string `gorm:"not null" json:"jobTitle"`
|
||||||
|
Company string `gorm:"not null" json:"company"`
|
||||||
|
Location *string `json:"location"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
Status string `gorm:"not null" json:"status"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
AppliedAt *time.Time `json:"appliedAt"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func migrateDatabase(db *gorm.DB) error {
|
|||||||
&models.Favorite{},
|
&models.Favorite{},
|
||||||
&models.Rowing{},
|
&models.Rowing{},
|
||||||
&models.Message{},
|
&models.Message{},
|
||||||
|
&models.JobApplication{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import CVGeneral from "./CVGeneral.vue";
|
|||||||
import CVBackend from "./CVBackend.vue";
|
import CVBackend from "./CVBackend.vue";
|
||||||
import CVFrontend from "./CVFrontend.vue";
|
import CVFrontend from "./CVFrontend.vue";
|
||||||
import CVTemp from "./CVTemp.vue";
|
import CVTemp from "./CVTemp.vue";
|
||||||
|
import JobApplications from "./JobApplications.vue";
|
||||||
|
|
||||||
const CVHospitality = defineAsyncComponent(() => import("./CVHospitality.vue"));
|
const CVHospitality = defineAsyncComponent(() => import("./CVHospitality.vue"));
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ function print() {
|
|||||||
<Transition name="cv-fade" mode="out-in">
|
<Transition name="cv-fade" mode="out-in">
|
||||||
<component :is="currentComponent" :key="selected" />
|
<component :is="currentComponent" :key="selected" />
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<JobApplications />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
390
vue/src/views/CV/JobApplications.vue
Normal file
390
vue/src/views/CV/JobApplications.vue
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { gql } from "@/graphql";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const applications = ref([]);
|
||||||
|
const editingId = ref(null);
|
||||||
|
const editForm = ref({});
|
||||||
|
const form = ref({
|
||||||
|
jobTitle: "",
|
||||||
|
company: "",
|
||||||
|
location: "",
|
||||||
|
url: "",
|
||||||
|
status: "Applied",
|
||||||
|
notes: "",
|
||||||
|
appliedAt: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["Applied", "Screening", "Interview", "Offer", "Rejected", "Withdrawn"];
|
||||||
|
|
||||||
|
const APP_FIELDS = `id jobTitle company location url status notes appliedAt createdAt`;
|
||||||
|
|
||||||
|
async function fetchApplications() {
|
||||||
|
try {
|
||||||
|
const data = await gql(`query { jobApplications { ${APP_FIELDS} } }`);
|
||||||
|
applications.value = data.jobApplications;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApplication() {
|
||||||
|
if (!form.value.jobTitle || !form.value.company || !form.value.status) return;
|
||||||
|
try {
|
||||||
|
const input = {
|
||||||
|
jobTitle: form.value.jobTitle,
|
||||||
|
company: form.value.company,
|
||||||
|
status: form.value.status,
|
||||||
|
location: form.value.location || undefined,
|
||||||
|
url: form.value.url || undefined,
|
||||||
|
notes: form.value.notes || undefined,
|
||||||
|
appliedAt: form.value.appliedAt || undefined,
|
||||||
|
};
|
||||||
|
const data = await gql(
|
||||||
|
`mutation CreateJobApplication($input: CreateJobApplicationInput!) {
|
||||||
|
createJobApplication(input: $input) { ${APP_FIELDS} }
|
||||||
|
}`,
|
||||||
|
{ input },
|
||||||
|
);
|
||||||
|
applications.value.unshift(data.createJobApplication);
|
||||||
|
form.value = { jobTitle: "", company: "", location: "", url: "", status: "Applied", notes: "", appliedAt: "" };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(app) {
|
||||||
|
editingId.value = app.id;
|
||||||
|
editForm.value = {
|
||||||
|
jobTitle: app.jobTitle,
|
||||||
|
company: app.company,
|
||||||
|
location: app.location ?? "",
|
||||||
|
url: app.url ?? "",
|
||||||
|
status: app.status,
|
||||||
|
notes: app.notes ?? "",
|
||||||
|
appliedAt: app.appliedAt ? app.appliedAt.substring(0, 10) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId.value = null;
|
||||||
|
editForm.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(id) {
|
||||||
|
try {
|
||||||
|
const input = {
|
||||||
|
jobTitle: editForm.value.jobTitle || undefined,
|
||||||
|
company: editForm.value.company || undefined,
|
||||||
|
status: editForm.value.status || undefined,
|
||||||
|
location: editForm.value.location || undefined,
|
||||||
|
url: editForm.value.url || undefined,
|
||||||
|
notes: editForm.value.notes || undefined,
|
||||||
|
appliedAt: editForm.value.appliedAt || undefined,
|
||||||
|
};
|
||||||
|
const data = await gql(
|
||||||
|
`mutation UpdateJobApplication($id: ID!, $input: UpdateJobApplicationInput!) {
|
||||||
|
updateJobApplication(id: $id, input: $input) { ${APP_FIELDS} }
|
||||||
|
}`,
|
||||||
|
{ id, input },
|
||||||
|
);
|
||||||
|
const idx = applications.value.findIndex((a) => a.id === id);
|
||||||
|
if (idx !== -1) applications.value[idx] = data.updateJobApplication;
|
||||||
|
editingId.value = null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApplication(id) {
|
||||||
|
try {
|
||||||
|
await gql(
|
||||||
|
`mutation DeleteJobApplication($id: ID!) { deleteJobApplication(id: $id) }`,
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
applications.value = applications.value.filter((a) => a.id !== id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
const map = {
|
||||||
|
Applied: "status-applied",
|
||||||
|
Screening: "status-screening",
|
||||||
|
Interview: "status-interview",
|
||||||
|
Offer: "status-offer",
|
||||||
|
Rejected: "status-rejected",
|
||||||
|
Withdrawn: "status-withdrawn",
|
||||||
|
};
|
||||||
|
return map[status] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!auth.user.admin) {
|
||||||
|
router.push("/admin");
|
||||||
|
} else {
|
||||||
|
fetchApplications();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="no-print ja-root">
|
||||||
|
<h2 class="ja-heading">Job Applications</h2>
|
||||||
|
|
||||||
|
<form class="ja-form" @submit.prevent="createApplication">
|
||||||
|
<div class="ja-form-row">
|
||||||
|
<input v-model="form.jobTitle" class="ja-input" placeholder="Job title *" required />
|
||||||
|
<input v-model="form.company" class="ja-input" placeholder="Company *" required />
|
||||||
|
<select v-model="form.status" class="ja-input ja-select">
|
||||||
|
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ s }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ja-form-row">
|
||||||
|
<input v-model="form.location" class="ja-input" placeholder="Location" />
|
||||||
|
<input v-model="form.url" class="ja-input" placeholder="URL" />
|
||||||
|
<input v-model="form.appliedAt" class="ja-input" type="date" title="Applied date" />
|
||||||
|
</div>
|
||||||
|
<div class="ja-form-row">
|
||||||
|
<textarea v-model="form.notes" class="ja-input ja-textarea" placeholder="Notes" />
|
||||||
|
<button type="submit" class="ja-btn ja-btn-primary">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table class="ja-table" v-if="applications.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Company</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Applied</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="app in applications" :key="app.id">
|
||||||
|
<tr v-if="editingId !== app.id">
|
||||||
|
<td>
|
||||||
|
<a v-if="app.url" :href="app.url" target="_blank" rel="noopener" class="ja-link">{{ app.jobTitle }}</a>
|
||||||
|
<span v-else>{{ app.jobTitle }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ app.company }}</td>
|
||||||
|
<td><span :class="['ja-badge', statusClass(app.status)]">{{ app.status }}</span></td>
|
||||||
|
<td>{{ app.location ?? "—" }}</td>
|
||||||
|
<td>{{ app.appliedAt ? app.appliedAt.substring(0, 10) : "—" }}</td>
|
||||||
|
<td class="ja-notes-cell">{{ app.notes ?? "" }}</td>
|
||||||
|
<td class="ja-actions">
|
||||||
|
<button class="ja-btn ja-btn-sm" @click="startEdit(app)">Edit</button>
|
||||||
|
<button class="ja-btn ja-btn-sm ja-btn-danger" @click="deleteApplication(app.id)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else class="ja-edit-row">
|
||||||
|
<td>
|
||||||
|
<input v-model="editForm.jobTitle" class="ja-input ja-input-sm" placeholder="Job title" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input v-model="editForm.company" class="ja-input ja-input-sm" placeholder="Company" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select v-model="editForm.status" class="ja-input ja-input-sm ja-select">
|
||||||
|
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ s }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input v-model="editForm.location" class="ja-input ja-input-sm" placeholder="Location" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input v-model="editForm.appliedAt" class="ja-input ja-input-sm" type="date" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input v-model="editForm.notes" class="ja-input ja-input-sm" placeholder="Notes" />
|
||||||
|
</td>
|
||||||
|
<td class="ja-actions">
|
||||||
|
<button class="ja-btn ja-btn-sm ja-btn-primary" @click="saveEdit(app.id)">Save</button>
|
||||||
|
<button class="ja-btn ja-btn-sm" @click="cancelEdit">Cancel</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="ja-empty">No applications yet.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ja-root {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 2px solid #333;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-heading {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-input {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: white;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-input-sm {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-btn {
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-btn:hover {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-btn-sm {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-btn-primary {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-btn-primary:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-btn-danger {
|
||||||
|
border-color: #c00;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-btn-danger:hover {
|
||||||
|
background: #fee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-table th,
|
||||||
|
.ja-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-table tr:hover td {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-edit-row td {
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-notes-cell {
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-link {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ja-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-applied { background: #dbeafe; color: #1e40af; }
|
||||||
|
.status-screening { background: #fef9c3; color: #854d0e; }
|
||||||
|
.status-interview { background: #ede9fe; color: #5b21b6; }
|
||||||
|
.status-offer { background: #dcfce7; color: #166534; }
|
||||||
|
.status-rejected { background: #fee2e2; color: #991b1b; }
|
||||||
|
.status-withdrawn { background: #f3f4f6; color: #6b7280; }
|
||||||
|
|
||||||
|
.ja-empty {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const username = ref("");
|
const username = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
|
|
||||||
function handleLogin() {
|
async function handleLogin() {
|
||||||
auth.logIn(username.value, password.value);
|
await auth.logIn(username.value, password.value);
|
||||||
|
if (auth.loggedIn && route.query.redirect) {
|
||||||
|
router.push(route.query.redirect);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
|||||||
Reference in New Issue
Block a user