diff --git a/README.md b/README.md index 28fbca0..ac80a0a 100644 --- a/README.md +++ b/README.md @@ -20,84 +20,141 @@ Track applications across platforms, manage companies, schedule interviews, and ---
-**WIP**: This is a work in progress. The project is not yet ready for production, changes are expected. It is still stable to use. Database migrations (Alembic) support schema updates across versions. +**WIP.** This is a work in progress. The project is not yet ready for production, breaking changes are possible. It is stable to use. Database migrations support schema updates across versions. -## What it does +## Stack + +| Layer | Tech | +|-------|------| +| Frontend | Next.js 16, React 19, Tailwind CSS, shadcn/ui, Turborepo | +| Backend | FastAPI, SQLAlchemy, Alembic | +| Data | PostgreSQL | +| Runtime | Docker | +| Testing | Vitest + React Testing Library, Pytest | + + +## Features + +### Dashboard + +Default home when you load the app (`/dashboard`). + +- Application count (with offers/rejections), response rate, average days per stage +- Week strip of upcoming appointments +- Stage distribution (pie chart), recent applications, platform conversion ranking, weekly heatmap +- Hide any widget from Settings + +### Applications + +Primary list for every application you track. + +- Filter by status, stage, platform, company, and active vs archived; column sort; pagination +- Stage history (add, edit, remove entries) +- Archive and restore +- Detail view from the job title +- Create and edit: job title, company, platform, posting URL, contract type, seniority, salary, applied date, stage, status, resume +- Linked resume; appointments scoped to that application +- Extra columns and compact rows (Settings) + +### Companies + +Directory of employers. + +- Name, website, notes +- New company: links applications that already used the same company text but were not linked yet +- Rename company: updates the company text on applications already linked to that record + +### Platforms + +Job boards and career sites you record applications against. -| Area | Capabilities | -|------|--------------| -| **Applications** | Job title, company, platform, salary, seniority, stages, history, archiving | -| **Companies** | Name, website, notes, linked to applications | -| **Platforms** | Job boards (LinkedIn, Indeed, etc.) with templates for quick entry | -| **Profile** | Resumes and profile data for fast attachment to applications | -| **Calendar** | Appointments and interviews with meeting URLs | -| **Dashboard** | Summary cards, status distribution, platform ranking, weekly heatmap | +- Dedicated page: create, edit, and delete platforms +- Each row: name, optional icon, base URL and "applications" URL (open from the table), **manual resume** flag for boards where you fill a CV on their site +- Built-in **templates** pre-fill name, icon, and URLs when you add or edit a platform +- Applications pick a platform; the applications list can filter by platform +### Calendar +Full-month schedule. + +- Month grid and agenda views +- Event types: interview, assessment, project, meeting, other +- Optional meeting URL and optional link to an application + +### Profile + +Resumes and text you reuse on forms. + +- CV upload, rename, archive, delete, download +- Presets: full name, email, phone, LinkedIn, GitHub, portfolio +- Customizable key/value rows + +### Settings + +Global display and layout. + +- 12h or 24h, timezone, locale +- Dashboard: show or hide each widget; week strip starts expanded or collapsed +- Applications table: optional resume, salary, seniority, and created-at columns; compact row density + +--- ## Stack | Layer | Tech | |-------|------| -| Frontend | Next.js 16, React 19, Tailwind, shadcn/ui, Turborepo | +| Frontend | Next.js 16, React 19, Tailwind CSS, shadcn/ui, Turborepo | | Backend | FastAPI, SQLAlchemy, Alembic | | Data | PostgreSQL | | Runtime | Docker | +| Testing | Vitest + React Testing Library, Pytest | ## Setup -### Docker +You can run the application via Docker or locally. + + +**Note:** +Running migrations is required on first setup. Also run after pulling updates that include new migrations. -**Prerequisites:** Docker +**Prerequisites:** +- Setup up `backend/.env` (see `backend/.env.example`) +- Setup up `frontend/.env` (see `frontend/.env.example`) + + + +## Docker ```bash git clone https://github.com/leobrqz/JobAppliesTracker.git cd JobAppliesTracker ``` -Create `backend/.env`: -```env -DATABASE_URL=postgresql://postgres:root@localhost:5432/jobtracker -STORAGE_DIR=./storage -``` +Start the services: ```bash docker compose up --build -d ``` -Run migrations (required on first setup; also run after pulling updates that include new migrations): +Run migrations: ```bash docker exec jobappliestracker-backend alembic upgrade head ``` -| Service | URL | -|---------|-----| -| App | [http://localhost:3000](http://localhost:3000) | -| API | [http://localhost:8000](http://localhost:8000) | -| API docs | [http://localhost:8000/docs](http://localhost:8000/docs) | -### Local Environment +## Local Environment **Prerequisites:** Node 20+, pnpm, Python 3.11+, PostgreSQL -1. Start PostgreSQL. Create database `jobtracker`: - -``` -createdb jobtracker -``` - -2. Create `backend/.env`: +### 1. PostgreSQL +Start PostgreSQL and create database `jobtracker` -``` -DATABASE_URL=postgresql://postgres:root@localhost:5432/jobtracker -STORAGE_DIR=./storage -``` -3. Backend: +### 2. Backend **Prerequisites:** Set up Python virtual environment. @@ -106,17 +163,18 @@ cd backend pip install -r requirements.txt ``` -Run migrations (required on first setup; also run after pulling updates that include new migrations): +Run migrations: ```bash alembic upgrade head ``` +Run the backend: ```bash uvicorn app.main:app --reload ``` -4. Frontend (another terminal): +### 3. Frontend: ```bash cd frontend diff --git a/backend/.env.example b/backend/.env.example index 25bb9c8..640eddb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,9 @@ -DATABASE_URL=postgresql://postgres:root@localhost:5432/jobtracker +# Local +DATABASE_URL=postgresql://{DB_USER}:{DB_PASSWORD}@localhost:5432/jobtracker STORAGE_DIR=./storage -# Comma-separated browser origins allowed for CORS (must match your Next.js URL) -CORS_ORIGINS=http://localhost:3000 +CORS_ORIGINS=http://localhost:3000 # must match your Next.js URL + +# Docker +DATABASE_URL=postgresql://{DB_USER}:{DB_PASSWORD}@postgres:5432/jobtracker +STORAGE_DIR=./storage +CORS_ORIGINS=http://localhost:3000 # must match your Next.js URL diff --git a/backend/app/routes/application_history.py b/backend/app/routes/application_history.py index 8f67df1..897d337 100644 --- a/backend/app/routes/application_history.py +++ b/backend/app/routes/application_history.py @@ -2,7 +2,11 @@ from sqlalchemy.orm import Session from app.core.database import get_db -from app.schemas.application_history import ApplicationHistoryCreate, ApplicationHistoryResponse +from app.schemas.application_history import ( + ApplicationHistoryCreate, + ApplicationHistoryResponse, + ApplicationHistoryUpdate, +) from app.services import application_history as history_service router = APIRouter(prefix="/api/applications", tags=["application-history"]) @@ -20,6 +24,16 @@ def add_history_entry( return history_service.advance_stage(db, application_id, data) +@router.patch("/{application_id}/history/{history_id}", response_model=ApplicationHistoryResponse) +def patch_history_entry( + application_id: int, + history_id: int, + data: ApplicationHistoryUpdate, + db: Session = Depends(get_db), +) -> ApplicationHistoryResponse: + return history_service.update_history_entry(db, application_id, history_id, data) + + @router.delete("/{application_id}/history/{history_id}", status_code=204) def delete_history_entry( application_id: int, history_id: int, db: Session = Depends(get_db) diff --git a/backend/app/schemas/application_history.py b/backend/app/schemas/application_history.py index edc1e44..f33a871 100644 --- a/backend/app/schemas/application_history.py +++ b/backend/app/schemas/application_history.py @@ -10,6 +10,12 @@ class ApplicationHistoryCreate(BaseModel): notes: Optional[str] = None +class ApplicationHistoryUpdate(BaseModel): + stage: Optional[str] = None + date: Optional[datetime] = None + notes: Optional[str] = None + + class ApplicationHistoryResponse(BaseModel): model_config = {"from_attributes": True} diff --git a/backend/app/services/application_history.py b/backend/app/services/application_history.py index adaaa70..a26c792 100644 --- a/backend/app/services/application_history.py +++ b/backend/app/services/application_history.py @@ -3,8 +3,7 @@ from app.models.application import Application from app.models.application_history import ApplicationHistory -from app.schemas.application_history import ApplicationHistoryCreate -from app.core import utcnow +from app.schemas.application_history import ApplicationHistoryCreate, ApplicationHistoryUpdate def get_history(db: Session, application_id: int) -> list[ApplicationHistory]: @@ -16,6 +15,18 @@ def get_history(db: Session, application_id: int) -> list[ApplicationHistory]: ) +def _get_latest_history_entry( + db: Session, + application_id: int, + *, + exclude_history_id: int | None = None, +) -> ApplicationHistory | None: + q = db.query(ApplicationHistory).filter(ApplicationHistory.application_id == application_id) + if exclude_history_id is not None: + q = q.filter(ApplicationHistory.id != exclude_history_id) + return q.order_by(ApplicationHistory.date.desc()).first() + + def _append_history_and_update_stage( db: Session, application: Application, @@ -52,6 +63,55 @@ def advance_stage(db: Session, application_id: int, data: ApplicationHistoryCrea return entry +def update_history_entry( + db: Session, + application_id: int, + history_id: int, + data: ApplicationHistoryUpdate, +) -> ApplicationHistory: + entry = ( + db.query(ApplicationHistory) + .filter( + ApplicationHistory.id == history_id, + ApplicationHistory.application_id == application_id, + ) + .first() + ) + if entry is None: + raise HTTPException(status_code=404, detail="History entry not found") + + updates = data.model_dump(exclude_unset=True) + if not updates: + raise HTTPException(status_code=400, detail="At least one field must be provided") + + if "stage" in updates: + stage_val = updates["stage"] + if stage_val is None or (isinstance(stage_val, str) and not stage_val.strip()): + raise HTTPException(status_code=400, detail="Stage cannot be empty") + entry.stage = stage_val.strip() + + if "date" in updates: + date_val = updates["date"] + if date_val is None: + raise HTTPException(status_code=400, detail="Date cannot be null") + entry.date = date_val + + if "notes" in updates: + entry.notes = updates["notes"] + + application = db.query(Application).filter(Application.id == application_id).first() + if application is None: + raise HTTPException(status_code=404, detail="Application not found") + + latest = _get_latest_history_entry(db, application_id) + if latest is not None: + application.current_stage = latest.stage + + db.commit() + db.refresh(entry) + return entry + + def delete_history_entry(db: Session, application_id: int, history_id: int) -> bool: entry = ( db.query(ApplicationHistory) @@ -77,15 +137,7 @@ def delete_history_entry(db: Session, application_id: int, history_id: int) -> b db.delete(entry) - latest = ( - db.query(ApplicationHistory) - .filter( - ApplicationHistory.application_id == application_id, - ApplicationHistory.id != history_id, - ) - .order_by(ApplicationHistory.date.desc()) - .first() - ) + latest = _get_latest_history_entry(db, application_id, exclude_history_id=history_id) application = db.query(Application).filter(Application.id == application_id).first() if application and latest: application.current_stage = latest.stage diff --git a/frontend/apps/web/.env.example b/frontend/apps/web/.env.example new file mode 100644 index 0000000..eb3d2da --- /dev/null +++ b/frontend/apps/web/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:8000 # must match your API URL \ No newline at end of file diff --git a/frontend/apps/web/__tests__/ApplicationTable.test.tsx b/frontend/apps/web/__tests__/ApplicationTable.test.tsx new file mode 100644 index 0000000..849da94 --- /dev/null +++ b/frontend/apps/web/__tests__/ApplicationTable.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi, beforeEach } from "vitest" +import { ApplicationTable } from "@/app/applications/ApplicationTable" +import type { ApplicationResponse, CompanyResponse, ResumeResponse } from "@/types" + +const { sampleApp, StageHistoryDialogSpy } = vi.hoisted(() => { + const sampleApp: ApplicationResponse = { + id: 7, + platform_id: 1, + job_title: "Backend Dev", + company: "Globex", + company_id: null, + salary: null, + seniority: null, + contract_type: null, + application_url: null, + status: "active", + current_stage: "screening", + applied_at: "2025-02-01T00:00:00.000Z", + resume_id: null, + archived_at: null, + created_at: "2025-02-01T00:00:00.000Z", + updated_at: "2025-02-01T00:00:00.000Z", + } + return { sampleApp, StageHistoryDialogSpy: vi.fn() } +}) + +vi.mock("@/components/StageHistoryDialog", () => ({ + StageHistoryDialog: (props: { + applicationId: number + open: boolean + onOpenChange: (open: boolean) => void + onStageChanged?: () => void + }) => { + StageHistoryDialogSpy(props) + return null + }, +})) + +vi.mock("@/services/applications.service", () => ({ + archiveApplication: vi.fn().mockResolvedValue({ data: sampleApp, error: null }), + restoreApplication: vi.fn().mockResolvedValue({ data: sampleApp, error: null }), + deleteApplication: vi.fn().mockResolvedValue({ data: null, error: null }), +})) + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +describe("ApplicationTable", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("wires stage history to the row application id and refresh callback", async () => { + const user = userEvent.setup() + const onEdit = vi.fn() + const onRefresh = vi.fn() + + render( + } + companies={[] as CompanyResponse[]} + archived={false} + onEdit={onEdit} + onRefresh={onRefresh} + />, + ) + + await user.click(screen.getByRole("button", { name: /stage history/i })) + + expect(StageHistoryDialogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: 7, + open: true, + onStageChanged: onRefresh, + }), + ) + }) +}) diff --git a/frontend/apps/web/__tests__/StageHistoryDialog.test.tsx b/frontend/apps/web/__tests__/StageHistoryDialog.test.tsx new file mode 100644 index 0000000..62ccc36 --- /dev/null +++ b/frontend/apps/web/__tests__/StageHistoryDialog.test.tsx @@ -0,0 +1,151 @@ +import { render, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { toast } from "sonner" +import { describe, expect, it, vi, beforeEach } from "vitest" +import { StageHistoryDialog } from "@/components/StageHistoryDialog" +import * as applicationsService from "@/services/applications.service" + +const { mockHistory, mockRefetch } = vi.hoisted(() => ({ + mockHistory: [ + { + id: 10, + application_id: 1, + stage: "interview", + date: "2025-03-01T14:00:00.000Z", + notes: "First round", + created_at: "2025-03-01T15:00:00.000Z", + }, + ], + mockRefetch: vi.fn(), +})) + +vi.mock("@/services/applications.service", () => ({ + advanceStage: vi.fn(), + deleteHistoryEntry: vi.fn(), + updateHistoryEntry: vi.fn(), +})) + +vi.mock("@/hooks/useApplicationHistory", () => ({ + useApplicationHistory: () => ({ + data: mockHistory, + isLoading: false, + error: null, + refetch: mockRefetch, + }), +})) + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +describe("StageHistoryDialog", () => { + beforeEach(() => { + vi.clearAllMocks() + mockRefetch.mockResolvedValue(undefined) + }) + + it("shows stage history title and entries when open", () => { + render( + {}} />, + ) + + expect(screen.getByRole("dialog", { name: /stage history/i })).toBeInTheDocument() + expect(screen.getByText("interview")).toBeInTheDocument() + expect(screen.getByText("First round")).toBeInTheDocument() + }) + + it("opens edit dialog and calls updateHistoryEntry on save", async () => { + const user = userEvent.setup() + const onStageChanged = vi.fn() + vi.mocked(applicationsService.updateHistoryEntry).mockResolvedValue({ + data: { ...mockHistory[0], stage: "offer", notes: "First round" }, + error: null, + }) + + render( + {}} onStageChanged={onStageChanged} />, + ) + + await user.click(screen.getByRole("button", { name: /^edit$/i })) + + const editDialog = await screen.findByRole("dialog", { name: /edit history entry/i }) + expect(editDialog).toBeInTheDocument() + + const stageSelect = within(editDialog).getByRole("combobox", { name: /^stage$/i }) + await user.click(stageSelect) + const offerOption = await screen.findByRole("option", { name: /^offer$/i }) + await user.click(offerOption) + + await user.click(within(editDialog).getByRole("button", { name: /^save$/i })) + + await waitFor(() => { + expect(applicationsService.updateHistoryEntry).toHaveBeenCalledWith( + 1, + 10, + expect.objectContaining({ + stage: "offer", + notes: "First round", + }), + ) + }) + expect(mockRefetch).toHaveBeenCalled() + expect(onStageChanged).toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith("Entry updated") + }) + + it("calls advanceStage when adding a new entry", async () => { + const user = userEvent.setup() + const onStageChanged = vi.fn() + vi.mocked(applicationsService.advanceStage).mockResolvedValue({ + data: { + id: 11, + application_id: 1, + stage: "screening", + date: new Date().toISOString(), + notes: null, + created_at: new Date().toISOString(), + }, + error: null, + }) + + render( + {}} onStageChanged={onStageChanged} />, + ) + + const mainDialog = screen.getByRole("dialog", { name: /stage history/i }) + const stageSelect = within(mainDialog).getByRole("combobox", { name: /^stage$/i }) + await user.click(stageSelect) + const screening = await screen.findByRole("option", { name: /^screening$/i }) + await user.click(screening) + + await user.click(screen.getByRole("button", { name: /add entry/i })) + + await waitFor(() => { + expect(applicationsService.advanceStage).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + stage: "screening", + }), + ) + }) + expect(mockRefetch).toHaveBeenCalled() + expect(onStageChanged).toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith("Stage added") + }) + + it("does not call advanceStage when stage is missing", async () => { + const user = userEvent.setup() + + render( + {}} />, + ) + + await user.click(screen.getByRole("button", { name: /add entry/i })) + + expect(applicationsService.advanceStage).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith("Stage is required") + }) +}) diff --git a/frontend/apps/web/__tests__/applications-page.test.tsx b/frontend/apps/web/__tests__/applications-page.test.tsx new file mode 100644 index 0000000..72bb3c4 --- /dev/null +++ b/frontend/apps/web/__tests__/applications-page.test.tsx @@ -0,0 +1,95 @@ +import type { AnchorHTMLAttributes, ReactNode } from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi, beforeEach } from "vitest" +import ApplicationsPage from "@/app/applications/page" + +vi.mock("next/link", () => ({ + default ({ + children, + href, + ...rest + }: { children?: ReactNode; href: string } & AnchorHTMLAttributes) { + return ( + + {children} + + ) + }, +})) + +const mockRefetch = vi.fn() +const mockSetData = vi.fn() + +vi.mock("@/hooks/useApplications", () => ({ + useApplications: () => ({ + data: [ + { + id: 1, + platform_id: 1, + job_title: "Engineer", + company: "Acme", + company_id: null, + salary: null, + seniority: null, + contract_type: null, + application_url: null, + status: "active", + current_stage: "interview", + applied_at: "2025-01-01T00:00:00.000Z", + resume_id: null, + archived_at: null, + created_at: "2025-01-01T00:00:00.000Z", + updated_at: "2025-01-01T00:00:00.000Z", + }, + ], + isLoading: false, + error: null, + refetch: mockRefetch, + setData: mockSetData, + }), +})) + +vi.mock("@/hooks/usePlatforms", () => ({ + usePlatforms: () => ({ + data: [{ id: 1, name: "LinkedIn" }], + }), +})) + +vi.mock("@/hooks/useResumeMap", () => ({ + useResumeMap: () => ({ map: {} }), +})) + +vi.mock("@/hooks/useCompanies", () => ({ + useCompanies: () => ({ data: [] }), +})) + +vi.mock("@/app/applications/ApplicationForm", () => ({ + ApplicationForm: ({ open }: { open: boolean }) => + open ?
: null, +})) + +describe("ApplicationsPage", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders heading, filters, and application row", () => { + render() + + expect(screen.getByRole("heading", { name: /applications/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /new application/i })).toBeInTheDocument() + expect(screen.getByRole("button", { name: /^engineer$/i })).toBeInTheDocument() + expect(screen.getByText("Acme")).toBeInTheDocument() + expect(screen.getByText("interview")).toBeInTheDocument() + }) + + it("opens application form when New Application is clicked", async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole("button", { name: /new application/i })) + + expect(await screen.findByRole("dialog", { name: /application form mock/i })).toBeInTheDocument() + }) +}) diff --git a/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx b/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx index 41d3fbb..144d578 100644 --- a/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx +++ b/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx @@ -60,28 +60,29 @@ export function ApplicationDetailDialog({ return null } + const app = application + const companyRecord = - application.company_id != null - ? companies.find((c) => c.id === application.company_id) + app.company_id != null + ? companies.find((c) => c.id === app.company_id) : undefined function closeAndEdit() { onOpenChange(false) - onEdit(application) + onEdit(app) } function closeAndHistory() { onOpenChange(false) - onOpenHistory(application.id) + onOpenHistory(app.id) } function closeAndAppointments() { onOpenChange(false) - onOpenAppointments(application) + onOpenAppointments(app) } - const salaryNum = - application.salary != null && application.salary !== "" ? Number(application.salary) : null + const salaryNum = app.salary != null && app.salary !== "" ? Number(app.salary) : null const salaryText = salaryNum != null && !Number.isNaN(salaryNum) ? new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(salaryNum) diff --git a/frontend/apps/web/app/applications/ApplicationTable.tsx b/frontend/apps/web/app/applications/ApplicationTable.tsx index 4327c61..12c3e05 100644 --- a/frontend/apps/web/app/applications/ApplicationTable.tsx +++ b/frontend/apps/web/app/applications/ApplicationTable.tsx @@ -129,7 +129,7 @@ export function ApplicationTable({ ) const columns = useMemo((): ColumnDef[] => { - const base: ColumnDef[] = [ + const base = [ columnHelper.accessor("job_title", { header: "Job Title", cell: ({ row, getValue }) => ( @@ -163,7 +163,7 @@ export function ApplicationTable({ header: "Date Applied", cell: (info) => formatDate(info.getValue(), locale), }), - ] + ] as ColumnDef[] const optional: ColumnDef[] = [] @@ -184,7 +184,7 @@ export function ApplicationTable({ ) }, sortingFn: "alphanumeric", - }), + }) as ColumnDef, ) } @@ -220,7 +220,7 @@ export function ApplicationTable({ return av === bv ? 0 : av < bv ? -1 : 1 }, }, - ), + ) as ColumnDef, ) } @@ -235,7 +235,7 @@ export function ApplicationTable({ ) : ( ), - }), + }) as ColumnDef, ) } @@ -245,7 +245,7 @@ export function ApplicationTable({ id: "created_at", header: "Created", cell: (info) => formatDate(info.getValue(), locale), - }), + }) as ColumnDef, ) } @@ -268,7 +268,12 @@ export function ApplicationTable({ - @@ -382,7 +387,7 @@ export function ApplicationTable({
) }, - }) + }) as ColumnDef return [...base, ...optional, actions] }, [tablePrefs, platforms, resumeMap, locale, archived, onEdit, handleArchive, handleRestore, handleDelete]) diff --git a/frontend/apps/web/app/calendar/page.tsx b/frontend/apps/web/app/calendar/page.tsx index f0d5a27..87dbceb 100644 --- a/frontend/apps/web/app/calendar/page.tsx +++ b/frontend/apps/web/app/calendar/page.tsx @@ -256,10 +256,10 @@ export default function CalendarPage() { {sortedAppointments.map((appt) => ( - {formatDate(appt.starts_at)} + {formatDate(appt.starts_at, locale)} - {formatTimeRange(appt.starts_at, appt.ends_at)} + {formatTimeRange(appt.starts_at, appt.ends_at, locale, timeFormat)} {appt.title} diff --git a/frontend/apps/web/app/settings/page.tsx b/frontend/apps/web/app/settings/page.tsx index 3a91837..73f40a0 100644 --- a/frontend/apps/web/app/settings/page.tsx +++ b/frontend/apps/web/app/settings/page.tsx @@ -308,7 +308,7 @@ function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) { aria-label="Select timezone" > - {label === "" ? "Auto" : label} + {label} diff --git a/frontend/apps/web/components/AppointmentListDialog.tsx b/frontend/apps/web/components/AppointmentListDialog.tsx index b7a57f5..866bdb1 100644 --- a/frontend/apps/web/components/AppointmentListDialog.tsx +++ b/frontend/apps/web/components/AppointmentListDialog.tsx @@ -108,6 +108,8 @@ export function AppointmentListDialog({ @@ -124,6 +126,8 @@ export function AppointmentListDialog({ void onDelete: (id: number) => void isPast?: boolean diff --git a/frontend/apps/web/components/StageHistoryDialog.tsx b/frontend/apps/web/components/StageHistoryDialog.tsx index 8d7af36..8f89201 100644 --- a/frontend/apps/web/components/StageHistoryDialog.tsx +++ b/frontend/apps/web/components/StageHistoryDialog.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" import { toast } from "sonner" import { AlertDialog, @@ -14,14 +14,15 @@ import { AlertDialogTrigger, } from "@workspace/ui/components/alert-dialog" import { Button } from "@workspace/ui/components/button" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@workspace/ui/components/dialog" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@workspace/ui/components/dialog" import { Input } from "@workspace/ui/components/input" import { Label } from "@workspace/ui/components/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@workspace/ui/components/select" import { Separator } from "@workspace/ui/components/separator" import { Textarea } from "@workspace/ui/components/textarea" import { useApplicationHistory } from "@/hooks/useApplicationHistory" -import { advanceStage, deleteHistoryEntry } from "@/services/applications.service" +import { advanceStage, deleteHistoryEntry, updateHistoryEntry } from "@/services/applications.service" +import type { ApplicationHistoryResponse } from "@/types" const STAGE_OPTIONS = ["application", "screening", "interview", "assessment", "offer", "closed"] @@ -50,6 +51,25 @@ export function StageHistoryDialog({ applicationId, open, onOpenChange, onStageC const [newNotes, setNewNotes] = useState("") const [submitting, setSubmitting] = useState(false) + const [editingEntry, setEditingEntry] = useState(null) + const [editStage, setEditStage] = useState("") + const [editDate, setEditDate] = useState("") + const [editNotes, setEditNotes] = useState("") + const [editSubmitting, setEditSubmitting] = useState(false) + + useEffect(() => { + if (!open) { + setEditingEntry(null) + } + }, [open]) + + function openEdit(entry: ApplicationHistoryResponse) { + setEditingEntry(entry) + setEditStage(entry.stage) + setEditDate(new Date(entry.date).toISOString().slice(0, 16)) + setEditNotes(entry.notes ?? "") + } + async function handleAddEntry() { if (!newStage) { toast.error("Stage is required") @@ -85,60 +105,137 @@ export function StageHistoryDialog({ applicationId, open, onOpenChange, onStageC } } + async function handleSaveEdit() { + if (!editingEntry) return + if (!editStage.trim()) { + toast.error("Stage is required") + return + } + setEditSubmitting(true) + const result = await updateHistoryEntry(applicationId, editingEntry.id, { + stage: editStage.trim(), + date: new Date(editDate).toISOString(), + notes: editNotes.trim() === "" ? null : editNotes.trim(), + }) + setEditSubmitting(false) + if (result.error) { + toast.error(result.error) + } else { + toast.success("Entry updated") + setEditingEntry(null) + await refetch() + onStageChanged?.() + } + } + return ( - - - - Stage History - - -
- {isLoading ? ( -

Loading…

- ) : history.length === 0 ? ( -

No history entries.

- ) : ( - history.map((entry) => ( -
-
-

{entry.stage}

-

{formatDateTime(entry.date)}

- {entry.notes &&

{entry.notes}

} -
- - - - - - - Delete history entry? - - This will remove the "{entry.stage}" stage entry. The current stage will be - recalculated from remaining entries. - - - - Cancel - handleDelete(entry.id)}>Delete - - - -
- )) - )} -
+ + + + + + + Delete history entry? + + This will remove the "{entry.stage}" stage entry. The current stage will be + recalculated from remaining entries. + + + + Cancel + handleDelete(entry.id)}>Delete + + + + + + )) + )} + - + -
-

Add Stage Entry

-
+
+

Add Stage Entry

+
+
+ + +
+
+ + setNewDate(e.target.value)} + /> +
+
- -