because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore(lint): apply Biome auto-fixes and organize imports across repo

+3029 -529
+9
.cursorrules
··· 381 381 - Utilities in `packages/utils/` 382 382 - Types in `packages/types/` 383 383 384 + ## JavaScript File Management 385 + 386 + ### Build Artifacts 387 + - **NEVER create JavaScript files** through TypeScript compilation or build commands 388 + - **Always clean up JavaScript files** after any build/compilation operations 389 + - Use `find . -name "*.js" -type f | grep -v node_modules | grep -v dist | xargs rm -f` to clean up 390 + - **Prefer TypeScript-only codebase** - no compiled JavaScript artifacts should remain 391 + - **After any build/test operations, immediately clean up generated JavaScript files** 392 + 384 393 ## Git & Commits 385 394 386 395 ### Commit Behavior
+3 -3
apps/client/src/components/ErrorBoundary.tsx
··· 1 1 import type React from "react"; 2 - import Button from "@/ui/Button"; 3 2 import { ErrorIcon } from "@/components/icons"; 3 + import Button from "@/ui/Button"; 4 4 5 5 interface ErrorBoundaryProps { 6 6 error?: Error | null; ··· 17 17 return ( 18 18 <div className="min-h-screen bg-ctp-base flex items-center justify-center p-4"> 19 19 <div className="max-w-md w-full"> 20 - <div 20 + <div 21 21 className="bg-ctp-surface0 border border-ctp-red/20 rounded-lg p-6 shadow-lg" 22 22 role="alert" 23 23 aria-live="assertive" ··· 98 98 return ( 99 99 <div className="min-h-screen bg-ctp-base flex items-center justify-center p-4"> 100 100 <div className="max-w-md w-full"> 101 - <div 101 + <div 102 102 className="bg-ctp-surface0 border border-ctp-red/20 rounded-lg p-6 shadow-lg" 103 103 role="alert" 104 104 aria-live="assertive"
+1 -1
apps/client/src/components/ServerStatusIndicator.tsx
··· 1 1 // Re-export the main component from the organized structure 2 - export { ServerStatusIndicator } from "./ServerStatusIndicator/index"; 2 + export { ServerStatusIndicator } from "./ServerStatusIndicator/index";
+12 -7
apps/client/src/components/ServerStatusIndicator/ServerTooltip.tsx
··· 4 4 /** 5 5 * Server status tooltip component 6 6 */ 7 - export const ServerTooltip = ({ 8 - statusConfig, 9 - serverInfo, 10 - lastChecked, 11 - position 7 + export const ServerTooltip = ({ 8 + statusConfig, 9 + serverInfo, 10 + lastChecked, 11 + position, 12 12 }: ServerTooltipProps) => ( 13 13 <div className={`absolute ${position}-0 top-6 z-50 hidden group-hover:block`}> 14 14 <div className="bg-ctp-crust border border-ctp-overlay0 rounded-lg p-3 shadow-lg min-w-48"> ··· 23 23 <div className="break-all">URL: {serverInfo.url}</div> 24 24 {serverInfo.timestamp && serverInfo.timezone && ( 25 25 <div className="pt-1 border-t border-ctp-overlay0"> 26 - <div>Server Time: {new Date(serverInfo.timestamp).toLocaleString()}</div> 26 + <div> 27 + Server Time: {new Date(serverInfo.timestamp).toLocaleString()} 28 + </div> 27 29 <div>Timezone: {serverInfo.timezone}</div> 28 30 {serverInfo.uptime && ( 29 31 <div>Uptime: {Math.floor(serverInfo.uptime / 1000 / 60)}m</div> ··· 34 36 <div className="pt-1 border-t border-ctporal0"> 35 37 Last checked: {lastChecked.toLocaleString()} 36 38 {formatLastChecked(lastChecked) && ( 37 - <span className="text-ctp-subtext1"> ({formatLastChecked(lastChecked)})</span> 39 + <span className="text-ctp-subtext1"> 40 + {" "} 41 + ({formatLastChecked(lastChecked)}) 42 + </span> 38 43 )} 39 44 </div> 40 45 )}
+9 -5
apps/client/src/components/ServerStatusIndicator/StatusDot.tsx
··· 9 9 /** 10 10 * Status dot component with CVA styling 11 11 */ 12 - export const StatusDot = ({ status, isChecking, statusConfig }: StatusDotProps) => ( 13 - <span 14 - className={statusVariants({ 15 - status, 16 - animated: isChecking 12 + export const StatusDot = ({ 13 + status, 14 + isChecking, 15 + statusConfig, 16 + }: StatusDotProps) => ( 17 + <span 18 + className={statusVariants({ 19 + status, 20 + animated: isChecking, 17 21 })} 18 22 > 19 23 {statusConfig.icon}
+1 -1
apps/client/src/components/ServerStatusIndicator/constants.ts
··· 7 7 variants: { 8 8 status: { 9 9 online: "text-ctp-green", 10 - offline: "text-ctp-red", 10 + offline: "text-ctp-red", 11 11 checking: "text-ctp-yellow", 12 12 }, 13 13 animated: {
+23 -25
apps/client/src/components/ServerStatusIndicator/index.tsx
··· 1 1 import { useServerHealth } from "@/hooks/useServerHealth"; 2 - import { getServerInfo, getStatusConfig } from "./utils"; 3 2 import { ServerTooltip } from "./ServerTooltip"; 4 3 import { StatusDot } from "./StatusDot"; 5 4 import type { ServerStatusIndicatorProps } from "./types"; 5 + import { getServerInfo, getStatusConfig } from "./utils"; 6 6 7 7 /** 8 8 * Server status indicator component with hover tooltip 9 9 * Shows server health status with detailed information on hover 10 10 */ 11 - export const ServerStatusIndicator = ({ 12 - className = "", 13 - showText = true, 14 - compact = false 11 + export const ServerStatusIndicator = ({ 12 + className = "", 13 + showText = true, 14 + compact = false, 15 15 }: ServerStatusIndicatorProps) => { 16 - const { 17 - status, 18 - lastChecked, 19 - isOnline, 20 - isOffline, 16 + const { 17 + status, 18 + lastChecked, 19 + isOnline, 20 + isOffline, 21 21 isChecking, 22 22 serverTime, 23 23 serverTimezone, 24 - serverUptime 24 + serverUptime, 25 25 } = useServerHealth(); 26 26 27 27 const serverInfo = getServerInfo(); ··· 32 32 ...serverInfo, 33 33 timestamp: serverTime, 34 34 timezone: serverTimezone, 35 - uptime: serverUptime 35 + uptime: serverUptime, 36 36 }; 37 37 38 38 if (compact) { 39 39 return ( 40 40 <div className={`relative group ${className}`}> 41 - <StatusDot 42 - status={status} 43 - isChecking={isChecking} 44 - statusConfig={statusConfig} 41 + <StatusDot 42 + status={status} 43 + isChecking={isChecking} 44 + statusConfig={statusConfig} 45 45 /> 46 - <ServerTooltip 46 + <ServerTooltip 47 47 statusConfig={statusConfig} 48 48 serverInfo={serverInfoWithHealth} 49 49 lastChecked={lastChecked} ··· 56 56 return ( 57 57 <div className={`relative group ${className}`}> 58 58 <div className="flex items-center gap-2"> 59 - <StatusDot 60 - status={status} 61 - isChecking={isChecking} 62 - statusConfig={statusConfig} 59 + <StatusDot 60 + status={status} 61 + isChecking={isChecking} 62 + statusConfig={statusConfig} 63 63 /> 64 64 {showText && ( 65 - <span className="text-ctp-text text-sm"> 66 - {statusConfig.text} 67 - </span> 65 + <span className="text-ctp-text text-sm">{statusConfig.text}</span> 68 66 )} 69 67 </div> 70 - <ServerTooltip 68 + <ServerTooltip 71 69 statusConfig={statusConfig} 72 70 serverInfo={serverInfoWithHealth} 73 71 lastChecked={lastChecked}
+7 -4
apps/client/src/components/ServerStatusIndicator/utils.ts
··· 1 1 import { getServerUrl, parseServerUrl } from "@/lib/config"; 2 - import type { ServerInfo } from "./types"; 3 2 import type { StatusConfig } from "./constants"; 3 + import type { ServerInfo } from "./types"; 4 4 5 5 /** 6 6 * Get server information from shared configuration ··· 8 8 export const getServerInfo = (): ServerInfo => { 9 9 const serverUrl = getServerUrl(); 10 10 const parsed = parseServerUrl(serverUrl); 11 - 11 + 12 12 return { 13 13 url: parsed.url, 14 14 hostname: parsed.hostname, 15 15 port: parsed.port, 16 - protocol: parsed.protocol 16 + protocol: parsed.protocol, 17 17 }; 18 18 }; 19 19 20 20 /** 21 21 * Get status configuration based on server state 22 22 */ 23 - export const getStatusConfig = (isOnline: boolean, isOffline: boolean): StatusConfig => { 23 + export const getStatusConfig = ( 24 + isOnline: boolean, 25 + isOffline: boolean, 26 + ): StatusConfig => { 24 27 if (isOnline) { 25 28 return { color: "text-ctp-green", icon: "●", text: "Server Online" }; 26 29 }
+6 -1
apps/client/src/components/icons/DeleteIcon.tsx
··· 7 7 */ 8 8 export default function DeleteIcon({ className = "w-4 h-4" }: DeleteIconProps) { 9 9 return ( 10 - <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 10 + <svg 11 + className={className} 12 + fill="none" 13 + stroke="currentColor" 14 + viewBox="0 0 24 24" 15 + > 11 16 <title>Delete icon</title> 12 17 <path 13 18 strokeLinecap="round"
+19
apps/client/src/components/icons/DocumentIcon.tsx
··· 1 + export default function DocumentIcon() { 2 + return ( 3 + <svg 4 + className="w-5 h-5" 5 + fill="none" 6 + stroke="currentColor" 7 + viewBox="0 0 24 24" 8 + aria-label="Document icon" 9 + > 10 + <title>Document</title> 11 + <path 12 + strokeLinecap="round" 13 + strokeLinejoin="round" 14 + strokeWidth={2} 15 + d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" 16 + /> 17 + </svg> 18 + ); 19 + }
+6 -1
apps/client/src/components/icons/EditIcon.tsx
··· 7 7 */ 8 8 export default function EditIcon({ className = "w-4 h-4" }: EditIconProps) { 9 9 return ( 10 - <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 10 + <svg 11 + className={className} 12 + fill="none" 13 + stroke="currentColor" 14 + viewBox="0 0 24 24" 15 + > 11 16 <title>Edit icon</title> 12 17 <path 13 18 strokeLinecap="round"
+19
apps/client/src/components/icons/LinkIcon.tsx
··· 1 + export default function LinkIcon() { 2 + return ( 3 + <svg 4 + className="w-5 h-5" 5 + fill="none" 6 + stroke="currentColor" 7 + viewBox="0 0 24 24" 8 + aria-label="Link icon" 9 + > 10 + <title>Link</title> 11 + <path 12 + strokeLinecap="round" 13 + strokeLinejoin="round" 14 + strokeWidth={2} 15 + d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" 16 + /> 17 + </svg> 18 + ); 19 + }
+8 -2
apps/client/src/components/icons/LoadingIcon.tsx
··· 5 5 /** 6 6 * Loading spinner icon component 7 7 */ 8 - export default function LoadingIcon({ className = "w-4 h-4" }: LoadingIconProps) { 8 + export default function LoadingIcon({ 9 + className = "w-4 h-4", 10 + }: LoadingIconProps) { 9 11 return ( 10 - <svg className={`${className} animate-spin`} fill="none" viewBox="0 0 24 24"> 12 + <svg 13 + className={`${className} animate-spin`} 14 + fill="none" 15 + viewBox="0 0 24 24" 16 + > 11 17 <title>Loading icon</title> 12 18 <circle 13 19 className="opacity-25"
+19
apps/client/src/components/icons/UploadIcon.tsx
··· 1 + export default function UploadIcon() { 2 + return ( 3 + <svg 4 + className="w-5 h-5" 5 + fill="none" 6 + stroke="currentColor" 7 + viewBox="0 0 24 24" 8 + aria-label="Upload icon" 9 + > 10 + <title>Upload</title> 11 + <path 12 + strokeLinecap="round" 13 + strokeLinejoin="round" 14 + strokeWidth={2} 15 + d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" 16 + /> 17 + </svg> 18 + ); 19 + }
+5 -2
apps/client/src/components/icons/index.ts
··· 1 1 export { default as CloseIcon } from "./CloseIcon"; 2 - export { default as ToastIcon } from "./ToastIcon"; 3 - export { default as EditIcon } from "./EditIcon"; 4 2 export { default as DeleteIcon } from "./DeleteIcon"; 3 + export { default as DocumentIcon } from "./DocumentIcon"; 4 + export { default as EditIcon } from "./EditIcon"; 5 5 export { default as ErrorIcon } from "./ErrorIcon"; 6 + export { default as LinkIcon } from "./LinkIcon"; 6 7 export { default as LoadingIcon } from "./LoadingIcon"; 8 + export { default as ToastIcon } from "./ToastIcon"; 9 + export { default as UploadIcon } from "./UploadIcon";
+12
apps/client/src/components/navLinks.ts
··· 1 + export type NavLink = { 2 + to: string; 3 + label: string; 4 + }; 5 + 6 + export const defaultNavLinks: NavLink[] = [ 7 + { to: "/", label: "Dashboard" }, 8 + { to: "/job-experience", label: "Job Experience" }, 9 + { to: "/organizations", label: "Organizations" }, 10 + { to: "/vacancies", label: "Vacancies" }, 11 + { to: "/profile", label: "Profile" }, 12 + ];
+2 -2
apps/client/src/features/auth/AuthForm.tsx apps/client/src/features/auth/components/AuthForm.tsx
··· 1 - import { type ReactNode } from "react"; 1 + import type { ReactNode } from "react"; 2 2 import { ServerStatusIndicator } from "@/components/ServerStatusIndicator"; 3 3 4 4 type AuthFormProps = { ··· 14 14 <h1 className="text-2xl font-semibold">{title}</h1> 15 15 <ServerStatusIndicator compact /> 16 16 </div> 17 - 17 + 18 18 {children} 19 19 </div> 20 20 </main>
+2 -2
apps/client/src/features/auth/LoginForm.tsx
··· 1 1 import { useState } from "react"; 2 2 import { Link } from "react-router-dom"; 3 3 import { useLogin } from "@/hooks/useAuth"; 4 - import AuthForm from "./AuthForm"; 5 4 import Button from "@/ui/Button"; 6 5 import TextInput from "@/ui/TextInput"; 6 + import AuthForm from "./AuthForm"; 7 7 8 8 export default function LoginForm() { 9 9 const [email, setEmail] = useState(""); ··· 29 29 placeholder="you@example.com" 30 30 value={email} 31 31 onChange={setEmail} 32 - error={error?.message ?? undefined} 32 + error={error?.message || undefined} 33 33 /> 34 34 <TextInput 35 35 label="Password"
+2 -2
apps/client/src/features/auth/RegisterForm.tsx
··· 1 1 import { useState } from "react"; 2 2 import { Link } from "react-router-dom"; 3 3 import { useRegister } from "@/hooks/useAuth"; 4 - import AuthForm from "./AuthForm"; 5 4 import Button from "@/ui/Button"; 6 5 import TextInput from "@/ui/TextInput"; 6 + import AuthForm from "./AuthForm"; 7 7 8 8 export default function RegisterForm() { 9 9 const [name, setName] = useState(""); ··· 37 37 placeholder="you@example.com" 38 38 value={email} 39 39 onChange={setEmail} 40 - error={error?.message ?? undefined} 40 + error={error?.message || undefined} 41 41 /> 42 42 <TextInput 43 43 label="Password"
+55
apps/client/src/features/auth/components/LoginForm.tsx
··· 1 + import { useState } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { useLogin } from "@/hooks/useAuth"; 4 + import Button from "@/ui/Button"; 5 + import TextInput from "@/ui/TextInput"; 6 + import AuthForm from "./AuthForm"; 7 + 8 + export default function LoginForm() { 9 + const [email, setEmail] = useState(""); 10 + const [password, setPassword] = useState(""); 11 + const [loginMutation, { loading, error }] = useLogin(); 12 + 13 + const handleSubmit = (e: React.FormEvent) => { 14 + e.preventDefault(); 15 + loginMutation({ 16 + variables: { 17 + email, 18 + password, 19 + }, 20 + }); 21 + }; 22 + 23 + return ( 24 + <AuthForm title="Welcome back"> 25 + <form className="space-y-4" onSubmit={handleSubmit}> 26 + <TextInput 27 + label="Email" 28 + type="email" 29 + placeholder="you@example.com" 30 + value={email} 31 + onChange={setEmail} 32 + error={error?.message || undefined} 33 + /> 34 + <TextInput 35 + label="Password" 36 + type="password" 37 + placeholder="••••••••" 38 + value={password} 39 + onChange={setPassword} 40 + /> 41 + <Button className="w-full" type="submit" disabled={loading}> 42 + {loading ? "Signing in..." : "Sign in"} 43 + </Button> 44 + <p className="mt-4 text-center text-sm text-ctp-subtext0"> 45 + No account yet?{" "} 46 + <Link to="/auth/register"> 47 + <Button variant="ghost" size="sm"> 48 + Register 49 + </Button> 50 + </Link> 51 + </p> 52 + </form> 53 + </AuthForm> 54 + ); 55 + }
+63
apps/client/src/features/auth/components/RegisterForm.tsx
··· 1 + import { useState } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { useRegister } from "@/hooks/useAuth"; 4 + import Button from "@/ui/Button"; 5 + import TextInput from "@/ui/TextInput"; 6 + import AuthForm from "./AuthForm"; 7 + 8 + export default function RegisterForm() { 9 + const [name, setName] = useState(""); 10 + const [email, setEmail] = useState(""); 11 + const [password, setPassword] = useState(""); 12 + const [register, { loading, error }] = useRegister(); 13 + 14 + const handleSubmit = (e: React.FormEvent) => { 15 + e.preventDefault(); 16 + register({ 17 + variables: { 18 + name, 19 + email, 20 + password, 21 + }, 22 + }); 23 + }; 24 + 25 + return ( 26 + <AuthForm title="Create account"> 27 + <form className="space-y-4" onSubmit={handleSubmit}> 28 + <TextInput 29 + label="Name" 30 + placeholder="Jane Doe" 31 + value={name} 32 + onChange={setName} 33 + /> 34 + <TextInput 35 + label="Email" 36 + type="email" 37 + placeholder="you@example.com" 38 + value={email} 39 + onChange={setEmail} 40 + error={error?.message || undefined} 41 + /> 42 + <TextInput 43 + label="Password" 44 + type="password" 45 + placeholder="••••••••" 46 + value={password} 47 + onChange={setPassword} 48 + /> 49 + <Button className="w-full" type="submit" disabled={loading}> 50 + {loading ? "Creating account..." : "Create account"} 51 + </Button> 52 + <p className="mt-4 text-center text-sm text-ctp-subtext0"> 53 + Already have an account?{" "} 54 + <Link to="/auth/login"> 55 + <Button variant="ghost" size="sm"> 56 + Login 57 + </Button> 58 + </Link> 59 + </p> 60 + </form> 61 + </AuthForm> 62 + ); 63 + }
+6
apps/client/src/features/auth/components/index.ts
··· 1 + /** 2 + * Auth feature components 3 + */ 4 + export { default as AuthForm } from "./AuthForm"; 5 + export { default as LoginForm } from "./LoginForm"; 6 + export { default as RegisterForm } from "./RegisterForm";
+6 -4
apps/client/src/features/job-experience/components/JobExperienceCard.tsx
··· 1 1 import { useState } from "react"; 2 2 import ConfirmationModal from "@/components/ConfirmationModal"; 3 + import { DeleteIcon, EditIcon, LoadingIcon } from "@/components/icons"; 3 4 import type { MeJobExperienceQuery } from "@/generated/graphql"; 5 + import IconButton from "@/ui/IconButton"; 4 6 import { calculateDuration, formatDateRange } from "@/utils/dateUtils"; 5 - import IconButton from "@/ui/IconButton"; 6 - import { EditIcon, DeleteIcon, LoadingIcon } from "@/components/icons"; 7 7 8 8 interface JobExperienceCardProps { 9 9 experience: MeJobExperienceQuery["myEmploymentHistory"][0]; 10 - onEdit?: (experience: MeJobExperienceQuery["myEmploymentHistory"][0]) => void; 11 - onDelete?: (experienceId: string) => void; 10 + onEdit?: 11 + | ((experience: MeJobExperienceQuery["myEmploymentHistory"][0]) => void) 12 + | undefined; 13 + onDelete?: ((experienceId: string) => void) | undefined; 12 14 isDeleting?: boolean; 13 15 } 14 16
+93
apps/client/src/features/job-experience/components/JobExperienceCreationSelector.tsx
··· 1 + import { useState } from "react"; 2 + import { CreationMethodCard } from "@/features/vacancies/components/VacancyCreationSelector/CreationMethodCard"; 3 + import { creationMethods } from "@/features/vacancies/components/VacancyCreationSelector/constants"; 4 + import { PlaceholderForm } from "@/features/vacancies/components/VacancyCreationSelector/PlaceholderForm"; 5 + import type { CreationMethod } from "@/features/vacancies/components/VacancyCreationSelector/types"; 6 + import Button from "@/ui/Button"; 7 + import { JobExperienceForm } from "./JobExperienceForm"; 8 + 9 + interface JobExperienceCreationSelectorProps { 10 + onSuccess?: () => void; 11 + onCancel?: () => void; 12 + } 13 + 14 + export const JobExperienceCreationSelector = ({ 15 + onSuccess, 16 + onCancel, 17 + }: JobExperienceCreationSelectorProps) => { 18 + const [selectedMethod, setSelectedMethod] = useState<CreationMethod>(null); 19 + 20 + // Lookup table for method-specific components 21 + const methodComponents: Record< 22 + Exclude<CreationMethod, null>, 23 + React.ReactNode 24 + > = { 25 + form: ( 26 + <JobExperienceForm 27 + onSuccess={onSuccess || (() => {})} 28 + onCancel={() => setSelectedMethod(null)} 29 + /> 30 + ), 31 + file: ( 32 + <PlaceholderForm 33 + title="Create from File" 34 + onBack={() => setSelectedMethod(null)} 35 + /> 36 + ), 37 + url: ( 38 + <PlaceholderForm 39 + title="Create from URL" 40 + onBack={() => setSelectedMethod(null)} 41 + /> 42 + ), 43 + paste: ( 44 + <PlaceholderForm 45 + title="Create from Text" 46 + onBack={() => setSelectedMethod(null)} 47 + /> 48 + ), 49 + }; 50 + 51 + // If a method is selected, show the appropriate component 52 + if (selectedMethod) { 53 + return methodComponents[selectedMethod]; 54 + } 55 + 56 + // Show method selection 57 + return ( 58 + <div className="space-y-6"> 59 + <div className="text-center"> 60 + <h2 className="text-xl font-semibold text-ctp-text mb-2"> 61 + Create New Job Experience 62 + </h2> 63 + <p className="text-ctp-subtext0"> 64 + Choose how you'd like to create your job experience 65 + </p> 66 + </div> 67 + 68 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 69 + {creationMethods.map((method) => { 70 + const IconComponent = method.icon; 71 + return ( 72 + <CreationMethodCard 73 + key={method.method} 74 + icon={<IconComponent />} 75 + title={method.title} 76 + description={method.description} 77 + color={method.color} 78 + onClick={() => setSelectedMethod(method.method)} 79 + /> 80 + ); 81 + })} 82 + </div> 83 + 84 + {onCancel && ( 85 + <div className="flex justify-end"> 86 + <Button variant="ghost" onClick={onCancel}> 87 + Cancel 88 + </Button> 89 + </div> 90 + )} 91 + </div> 92 + ); 93 + };
+164
apps/client/src/features/job-experience/components/JobExperienceTable.tsx
··· 1 + import { DeleteIcon, EditIcon } from "@/components/icons"; 2 + import { useConfirmationModal } from "@/contexts/ConfirmationModalContext"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import type { MeJobExperienceQuery } from "@/generated/graphql"; 5 + import { useDeleteJobExperienceMutation } from "@/generated/graphql"; 6 + import IconButton from "@/ui/IconButton"; 7 + import { 8 + Table, 9 + TableBody, 10 + TableCell, 11 + TableHeader, 12 + TableHeaderCell, 13 + TableRow, 14 + } from "@/ui/Table"; 15 + 16 + interface JobExperienceTableProps { 17 + experiences: MeJobExperienceQuery["myEmploymentHistory"]; 18 + onEdit?: (experience: MeJobExperienceQuery["myEmploymentHistory"][0]) => void; 19 + onDelete?: () => void; 20 + } 21 + 22 + export const JobExperienceTable = ({ 23 + experiences, 24 + onEdit, 25 + onDelete, 26 + }: JobExperienceTableProps) => { 27 + const [deleteJobExperience, { loading }] = useDeleteJobExperienceMutation(); 28 + const { showSuccess, showError } = useToast(); 29 + const { showConfirmation } = useConfirmationModal(); 30 + 31 + const handleDeleteClick = async (experienceId: string) => { 32 + const experience = experiences.find((e) => e.id === experienceId); 33 + 34 + const _confirmed = await showConfirmation({ 35 + title: "Delete Job Experience", 36 + message: `Are you sure you want to delete the ${experience?.role.name} position at ${experience?.company.name}? This action cannot be undone.`, 37 + confirmText: "Delete", 38 + cancelText: "Cancel", 39 + variant: "danger", 40 + onConfirm: async () => { 41 + try { 42 + await deleteJobExperience({ 43 + variables: { id: experienceId }, 44 + }); 45 + 46 + showSuccess( 47 + "Job Experience Deleted", 48 + `Successfully deleted ${experience?.role.name} position at ${experience?.company.name}`, 49 + ); 50 + 51 + onDelete?.(); 52 + } catch (error) { 53 + console.error("Error deleting job experience:", error); 54 + showError( 55 + "Failed to Delete Job Experience", 56 + "There was an error deleting the job experience. Please try again.", 57 + ); 58 + } 59 + }, 60 + }); 61 + }; 62 + 63 + const formatDate = (dateString: string) => { 64 + return new Date(dateString).toLocaleDateString(); 65 + }; 66 + 67 + const formatDateRange = (startDate: string, endDate?: string | null) => { 68 + const start = formatDate(startDate); 69 + const end = endDate ? formatDate(endDate) : "Present"; 70 + return `${start} - ${end}`; 71 + }; 72 + 73 + const getSkillsList = ( 74 + skills: Array<{ name: string }>, 75 + maxSkills: number = 3, 76 + ) => { 77 + if (skills.length === 0) return "—"; 78 + if (skills.length <= maxSkills) { 79 + return skills.map((skill) => skill.name).join(", "); 80 + } 81 + const displayedSkills = skills.slice(0, maxSkills); 82 + const remainingCount = skills.length - maxSkills; 83 + return `${displayedSkills.map((skill) => skill.name).join(", ")} (and ${remainingCount} more)`; 84 + }; 85 + 86 + if (experiences.length === 0) { 87 + return ( 88 + <div className="text-center py-8"> 89 + <div className="text-ctp-subtext0 mb-2">No job experiences found</div> 90 + <div className="text-sm text-ctp-subtext1"> 91 + Create your first job experience to get started 92 + </div> 93 + </div> 94 + ); 95 + } 96 + 97 + return ( 98 + <Table> 99 + <TableHeader> 100 + <TableRow> 101 + <TableHeaderCell>Company</TableHeaderCell> 102 + <TableHeaderCell>Role</TableHeaderCell> 103 + <TableHeaderCell>Level</TableHeaderCell> 104 + <TableHeaderCell>Duration</TableHeaderCell> 105 + <TableHeaderCell>Skills</TableHeaderCell> 106 + <TableHeaderCell>Actions</TableHeaderCell> 107 + </TableRow> 108 + </TableHeader> 109 + <TableBody> 110 + {experiences.map((experience) => ( 111 + <TableRow key={experience.id}> 112 + <TableCell> 113 + <div className="font-medium text-ctp-text"> 114 + {experience.company.name} 115 + </div> 116 + </TableCell> 117 + <TableCell> 118 + <div className="font-medium text-ctp-text"> 119 + {experience.role.name} 120 + </div> 121 + </TableCell> 122 + <TableCell> 123 + <div className="text-ctp-subtext0">{experience.level.name}</div> 124 + </TableCell> 125 + <TableCell> 126 + <div className="text-ctp-subtext0"> 127 + {formatDateRange(experience.startDate, experience.endDate)} 128 + </div> 129 + </TableCell> 130 + <TableCell> 131 + <div 132 + className="text-ctp-subtext0 max-w-xs truncate" 133 + title={experience.skills.map((s) => s.name).join(", ")} 134 + > 135 + {getSkillsList(experience.skills)} 136 + </div> 137 + </TableCell> 138 + <TableCell> 139 + <div className="flex items-center gap-2"> 140 + <IconButton 141 + icon={<EditIcon />} 142 + label="Edit experience" 143 + variant="ghost" 144 + size="sm" 145 + className="text-ctp-blue hover:bg-ctp-blue/20" 146 + onClick={() => onEdit?.(experience)} 147 + /> 148 + <IconButton 149 + icon={<DeleteIcon />} 150 + label="Delete experience" 151 + variant="ghost" 152 + size="sm" 153 + className="text-ctp-red hover:bg-ctp-red/20" 154 + onClick={() => handleDeleteClick(experience.id)} 155 + disabled={loading} 156 + /> 157 + </div> 158 + </TableCell> 159 + </TableRow> 160 + ))} 161 + </TableBody> 162 + </Table> 163 + ); 164 + };
+3
apps/client/src/features/job-experience/components/index.ts
··· 2 2 * Job Experience feature components 3 3 */ 4 4 export { default as JobExperienceCard } from "./JobExperienceCard"; 5 + export { JobExperienceCreationSelector } from "./JobExperienceCreationSelector"; 5 6 export { default as JobExperienceEmpty } from "./JobExperienceEmpty"; 7 + export { JobExperienceForm } from "./JobExperienceForm"; 6 8 export { default as JobExperienceHeader } from "./JobExperienceHeader"; 7 9 export { default as JobExperienceList } from "./JobExperienceList"; 8 10 export { default as JobExperienceLoading } from "./JobExperienceLoading"; 11 + export { JobExperienceTable } from "./JobExperienceTable";
+9
apps/client/src/features/job-experience/queries/companies-query.graphql
··· 1 + query Companies { 2 + companies { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + }
+42
apps/client/src/features/job-experience/queries/create-job-experience.graphql
··· 1 + mutation CreateJobExperience( 2 + $companyId: String! 3 + $roleId: String! 4 + $levelId: String! 5 + $startDate: DateTime! 6 + $endDate: DateTime 7 + $description: String 8 + $skillIds: [String!] 9 + ) { 10 + createJobExperience( 11 + companyId: $companyId 12 + roleId: $roleId 13 + levelId: $levelId 14 + startDate: $startDate 15 + endDate: $endDate 16 + description: $description 17 + skillIds: $skillIds 18 + ) { 19 + id 20 + company { 21 + id 22 + name 23 + } 24 + role { 25 + id 26 + name 27 + } 28 + level { 29 + id 30 + name 31 + } 32 + startDate 33 + endDate 34 + description 35 + skills { 36 + id 37 + name 38 + } 39 + createdAt 40 + updatedAt 41 + } 42 + }
+30
apps/client/src/features/job-experience/queries/job-experience-form-data.graphql
··· 1 + query JobExperienceFormData { 2 + companies { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + roles { 10 + id 11 + name 12 + description 13 + createdAt 14 + updatedAt 15 + } 16 + levels { 17 + id 18 + name 19 + description 20 + createdAt 21 + updatedAt 22 + } 23 + skills { 24 + id 25 + name 26 + description 27 + createdAt 28 + updatedAt 29 + } 30 + }
+9
apps/client/src/features/job-experience/queries/levels-query.graphql
··· 1 + query Levels { 2 + levels { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + }
+9
apps/client/src/features/job-experience/queries/roles-query.graphql
··· 1 + query Roles { 2 + roles { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + }
+9
apps/client/src/features/job-experience/queries/skills-query.graphql
··· 1 + query Skills { 2 + skills { 3 + id 4 + name 5 + description 6 + createdAt 7 + updatedAt 8 + } 9 + }
+4 -2
apps/client/src/features/organizations/components/MembersTableBody.tsx
··· 1 - import type { MeQuery } from "@/generated/graphql"; 1 + import type { MeWithOrganizationsQuery } from "@/generated/graphql"; 2 2 import { TableBody } from "@/ui/Table"; 3 3 import { OrganizationMemberRow } from "./OrganizationMemberRow"; 4 4 5 - type Organization = NonNullable<NonNullable<MeQuery["me"]>["organizations"]>[0]; 5 + type Organization = NonNullable< 6 + NonNullable<MeWithOrganizationsQuery["me"]>["organizations"] 7 + >[0]; 6 8 7 9 interface MembersTableBodyProps { 8 10 organization: Organization;
+44 -34
apps/client/src/features/organizations/components/OrganizationMemberRow.tsx
··· 2 2 import Badge, { type CatppuccinColor } from "@/ui/Badge"; 3 3 import { TableCell, TableRow } from "@/ui/Table"; 4 4 5 - type OrganizationMember = NonNullable<NonNullable<MeWithOrganizationsQuery["me"]>["organizations"]>[0]["users"][0]; 5 + type OrganizationMember = NonNullable< 6 + NonNullable<MeWithOrganizationsQuery["me"]>["organizations"] 7 + >[0]["users"][0]; 6 8 7 9 interface OrganizationMemberRowProps { 8 10 member: OrganizationMember; 9 11 } 10 12 11 - export const OrganizationMemberRow = ({ member }: OrganizationMemberRowProps) => { 12 - const hasExperience = member.user.experience && member.user.experience.length > 0; 13 + export const OrganizationMemberRow = ({ 14 + member, 15 + }: OrganizationMemberRowProps) => { 16 + const hasExperience = 17 + member.user.experience && member.user.experience.length > 0; 13 18 const currentPosition = hasExperience ? member.user.experience?.[0] : null; 14 19 15 20 return ( 16 21 <TableRow> 17 22 <TableCell> 18 23 <div> 19 - <div className="text-sm font-medium text-ctp-text">{member.user.name}</div> 24 + <div className="text-sm font-medium text-ctp-text"> 25 + {member.user.name} 26 + </div> 20 27 <div className="text-sm text-ctp-subtext0">{member.user.email}</div> 21 28 </div> 22 29 </TableCell> 23 - 24 - <TableCell> 25 - <div className="flex items-center"> 26 - <Badge color={member.role.color as CatppuccinColor}> 27 - {member.role.name} 28 - </Badge> 29 - </div> 30 - </TableCell> 31 - 30 + 31 + <TableCell> 32 + <div className="flex items-center"> 33 + {member.role ? ( 34 + <Badge color={member.role.color as CatppuccinColor}> 35 + {member.role.name} 36 + </Badge> 37 + ) : ( 38 + <span className="text-ctp-subtext0">No role</span> 39 + )} 40 + </div> 41 + </TableCell> 42 + 32 43 <TableCell> 33 44 {currentPosition ? ( 34 45 <div> 35 - <div className="font-medium"> 36 - {currentPosition.company.name} 37 - </div> 46 + <div className="font-medium">{currentPosition.company.name}</div> 38 47 <div className="text-ctp-subtext0"> 39 48 {currentPosition.role.name} • {currentPosition.level.name} 40 49 </div> ··· 43 52 <span className="text-ctp-subtext0">No experience data</span> 44 53 )} 45 54 </TableCell> 46 - 47 - <TableCell> 48 - {hasExperience ? ( 49 - <div> 50 - <div className="font-medium"> 51 - {member.user.experience?.length} position{member.user.experience?.length !== 1 ? "s" : ""} 52 - </div> 53 - <div className="text-ctp-subtext0"> 54 - {member.user.experience && member.user.experience.length > 1 && ( 55 - <span>+{member.user.experience.length - 1} more</span> 56 - )} 57 - </div> 58 - </div> 59 - ) : ( 60 - <span className="text-ctp-subtext0">No experience</span> 61 - )} 62 - </TableCell> 63 - 55 + 56 + <TableCell> 57 + {hasExperience ? ( 58 + <div> 59 + <div className="font-medium"> 60 + {member.user.experience?.length} position 61 + {member.user.experience?.length !== 1 ? "s" : ""} 62 + </div> 63 + <div className="text-ctp-subtext0"> 64 + {member.user.experience && member.user.experience.length > 1 && ( 65 + <span>+{member.user.experience.length - 1} more</span> 66 + )} 67 + </div> 68 + </div> 69 + ) : ( 70 + <span className="text-ctp-subtext0">No experience</span> 71 + )} 72 + </TableCell> 73 + 64 74 <TableCell className="text-ctp-subtext0"> 65 75 {new Date(member.joinedAt).toLocaleDateString()} 66 76 </TableCell>
+17 -7
apps/client/src/features/organizations/components/OrganizationMembersTable.tsx
··· 1 - import type { MeQuery } from "@/generated/graphql"; 1 + import type { MeWithOrganizationsQuery } from "@/generated/graphql"; 2 2 import { Table } from "@/ui/Table"; 3 3 import { MembersTableBody } from "./MembersTableBody"; 4 4 import { MembersTableHeader } from "./MembersTableHeader"; 5 5 6 - type Organization = NonNullable<NonNullable<MeQuery["me"]>["organizations"]>[0]; 6 + type Organization = NonNullable< 7 + NonNullable<MeWithOrganizationsQuery["me"]>["organizations"] 8 + >[0]; 7 9 8 10 interface OrganizationMembersTableProps { 9 11 organization: Organization; 10 12 } 11 13 12 - export const OrganizationMembersTable = ({ organization }: OrganizationMembersTableProps) => { 14 + export const OrganizationMembersTable = ({ 15 + organization, 16 + }: OrganizationMembersTableProps) => { 13 17 const members = organization?.users || []; 14 18 15 19 if (members.length === 0) { 16 20 return ( 17 21 <div className="bg-ctp-surface0 rounded-lg border border-ctp-surface1 p-6"> 18 - <h2 className="mb-4 text-lg font-semibold text-ctp-text">{organization.name} Members</h2> 22 + <h2 className="mb-4 text-lg font-semibold text-ctp-text"> 23 + {organization.name} Members 24 + </h2> 19 25 <div className="text-center text-ctp-subtext0"> 20 26 <div className="mb-4">No members found</div> 21 27 </div> ··· 26 32 return ( 27 33 <div className="bg-ctp-surface0 rounded-lg border border-ctp-surface1 p-6"> 28 34 <div className="mb-6"> 29 - <h2 className="text-xl font-semibold text-ctp-text">{organization.name} Members</h2> 35 + <h2 className="text-xl font-semibold text-ctp-text"> 36 + {organization.name} Members 37 + </h2> 30 38 {organization.description && ( 31 - <p className="mt-1 text-sm text-ctp-subtext0">{organization.description}</p> 39 + <p className="mt-1 text-sm text-ctp-subtext0"> 40 + {organization.description} 41 + </p> 32 42 )} 33 43 <div className="mt-2 text-sm text-ctp-subtext0"> 34 44 {members.length} member{members.length !== 1 ? "s" : ""} found 35 45 </div> 36 46 </div> 37 - 47 + 38 48 <Table> 39 49 <MembersTableHeader /> 40 50 <MembersTableBody organization={organization} />
+3 -3
apps/client/src/features/organizations/components/index.ts
··· 1 - export { OrganizationMembersTable } from "./OrganizationMembersTable"; 2 - export { OrganizationMemberRow } from "./OrganizationMemberRow"; 3 - export { MembersTableHeader } from "./MembersTableHeader"; 4 1 export { MembersTableBody } from "./MembersTableBody"; 2 + export { MembersTableHeader } from "./MembersTableHeader"; 3 + export { OrganizationMemberRow } from "./OrganizationMemberRow"; 4 + export { OrganizationMembersTable } from "./OrganizationMembersTable";
+187
apps/client/src/features/vacancies/components/VacancyCard.tsx
··· 1 + import { useState } from "react"; 2 + import ConfirmationModal from "@/components/ConfirmationModal"; 3 + import { DeleteIcon } from "@/components/icons"; 4 + import { useToast } from "@/contexts/ToastContext"; 5 + import { useDeleteVacancyMutation } from "@/generated/graphql"; 6 + import Button from "@/ui/Button"; 7 + import IconButton from "@/ui/IconButton"; 8 + 9 + interface VacancyCardProps { 10 + vacancy: { 11 + id: string; 12 + title: string; 13 + company: string; 14 + description?: string | null; 15 + requirements?: string | null; 16 + location?: string | null; 17 + salary?: string | null; 18 + jobType?: string | null; 19 + applicationUrl?: string | null; 20 + deadline?: string | null; 21 + isActive: boolean; 22 + createdAt: string; 23 + }; 24 + onDelete?: () => void; 25 + } 26 + 27 + export const VacancyCard = ({ vacancy, onDelete }: VacancyCardProps) => { 28 + const [deleteVacancy, { loading }] = useDeleteVacancyMutation(); 29 + const { showSuccess, showError } = useToast(); 30 + const [showDetails, setShowDetails] = useState(false); 31 + const [showDeleteModal, setShowDeleteModal] = useState(false); 32 + 33 + const handleDeleteClick = () => { 34 + setShowDeleteModal(true); 35 + }; 36 + 37 + const handleDeleteConfirm = async () => { 38 + try { 39 + await deleteVacancy({ 40 + variables: { id: vacancy.id }, 41 + }); 42 + 43 + // Show success toast 44 + showSuccess( 45 + "Vacancy Deleted", 46 + `Successfully deleted vacancy for ${vacancy.title} at ${vacancy.company}`, 47 + ); 48 + 49 + setShowDeleteModal(false); 50 + onDelete?.(); 51 + } catch (error) { 52 + console.error("Error deleting vacancy:", error); 53 + showError( 54 + "Failed to Delete Vacancy", 55 + "There was an error deleting the vacancy. Please try again.", 56 + ); 57 + } 58 + }; 59 + 60 + const handleDeleteCancel = () => { 61 + setShowDeleteModal(false); 62 + }; 63 + 64 + const formatDate = (dateString: string) => { 65 + return new Date(dateString).toLocaleDateString(); 66 + }; 67 + 68 + const formatDeadline = (deadlineString?: string | null) => { 69 + if (!deadlineString) return null; 70 + return new Date(deadlineString).toLocaleDateString(); 71 + }; 72 + 73 + return ( 74 + <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-6 shadow-sm"> 75 + <div className="flex items-start justify-between"> 76 + <div className="flex-1"> 77 + <div className="flex items-center gap-2 mb-2"> 78 + <h3 className="text-lg font-semibold text-ctp-text"> 79 + {vacancy.title} 80 + </h3> 81 + <span 82 + className={`px-2 py-1 text-xs rounded-full ${ 83 + vacancy.isActive 84 + ? "bg-ctp-green/20 text-ctp-green" 85 + : "bg-ctp-red/20 text-ctp-red" 86 + }`} 87 + > 88 + {vacancy.isActive ? "Active" : "Inactive"} 89 + </span> 90 + </div> 91 + 92 + <div className="text-ctp-subtext0 mb-2"> 93 + <div className="font-medium text-ctp-text">{vacancy.company}</div> 94 + {vacancy.location && <div>{vacancy.location}</div>} 95 + {vacancy.salary && <div>{vacancy.salary}</div>} 96 + {vacancy.jobType && <div>{vacancy.jobType}</div>} 97 + </div> 98 + 99 + {vacancy.deadline && ( 100 + <div className="text-sm text-ctp-subtext1 mb-2"> 101 + Deadline: {formatDeadline(vacancy.deadline)} 102 + </div> 103 + )} 104 + 105 + {showDetails && ( 106 + <div className="mt-4 space-y-3"> 107 + {vacancy.description && ( 108 + <div> 109 + <h4 className="font-medium text-ctp-text mb-1"> 110 + Description: 111 + </h4> 112 + <p className="text-sm text-ctp-subtext0 whitespace-pre-wrap"> 113 + {vacancy.description} 114 + </p> 115 + </div> 116 + )} 117 + 118 + {vacancy.requirements && ( 119 + <div> 120 + <h4 className="font-medium text-ctp-text mb-1"> 121 + Requirements: 122 + </h4> 123 + <p className="text-sm text-ctp-subtext0 whitespace-pre-wrap"> 124 + {vacancy.requirements} 125 + </p> 126 + </div> 127 + )} 128 + 129 + {vacancy.applicationUrl && ( 130 + <div> 131 + <h4 className="font-medium text-ctp-text mb-1"> 132 + Application: 133 + </h4> 134 + <a 135 + href={vacancy.applicationUrl} 136 + target="_blank" 137 + rel="noopener noreferrer" 138 + className="text-ctp-blue hover:underline text-sm" 139 + > 140 + {vacancy.applicationUrl} 141 + </a> 142 + </div> 143 + )} 144 + </div> 145 + )} 146 + 147 + <div className="text-xs text-ctp-subtext1 mt-2"> 148 + Created: {formatDate(vacancy.createdAt)} 149 + </div> 150 + </div> 151 + 152 + <div className="flex flex-col gap-2 ml-4"> 153 + <IconButton 154 + icon={<DeleteIcon />} 155 + label="Delete vacancy" 156 + onClick={handleDeleteClick} 157 + disabled={loading} 158 + className="text-ctp-red hover:bg-ctp-red/20" 159 + /> 160 + </div> 161 + </div> 162 + 163 + <div className="mt-4 pt-4 border-t border-ctp-surface1"> 164 + <Button 165 + variant="ghost" 166 + size="sm" 167 + onClick={() => setShowDetails(!showDetails)} 168 + className="text-ctp-text hover:bg-ctp-surface1" 169 + > 170 + {showDetails ? "Hide Details" : "Show Details"} 171 + </Button> 172 + </div> 173 + 174 + {/* Delete confirmation modal */} 175 + <ConfirmationModal 176 + isOpen={showDeleteModal} 177 + onClose={handleDeleteCancel} 178 + onConfirm={handleDeleteConfirm} 179 + title="Delete Vacancy" 180 + message={`Are you sure you want to delete the vacancy for ${vacancy.title} at ${vacancy.company}? This action cannot be undone.`} 181 + confirmText="Delete" 182 + cancelText="Cancel" 183 + variant="danger" 184 + /> 185 + </div> 186 + ); 187 + };
+37
apps/client/src/features/vacancies/components/VacancyCreationSelector/CreationMethodCard.tsx
··· 1 + import IconButton from "@/ui/IconButton"; 2 + import type { CreationMethodCardProps } from "./types"; 3 + import { 4 + creationMethodCardVariants, 5 + iconButtonVariants, 6 + iconContainerVariants, 7 + } from "./variants"; 8 + 9 + export const CreationMethodCard = ({ 10 + icon, 11 + title, 12 + description, 13 + color, 14 + onClick, 15 + }: CreationMethodCardProps) => { 16 + return ( 17 + <button 18 + type="button" 19 + onClick={onClick} 20 + className={creationMethodCardVariants({ color })} 21 + > 22 + <div className="flex items-center gap-3 mb-3"> 23 + <div className={iconContainerVariants({ color })}> 24 + <IconButton 25 + icon={icon} 26 + label={title} 27 + variant="ghost" 28 + size="sm" 29 + className={iconButtonVariants({ color })} 30 + /> 31 + </div> 32 + <h3 className="font-semibold text-ctp-text">{title}</h3> 33 + </div> 34 + <p className="text-sm text-ctp-subtext0">{description}</p> 35 + </button> 36 + ); 37 + };
+10
apps/client/src/features/vacancies/components/VacancyCreationSelector/PlaceholderForm.tsx
··· 1 + import Button from "@/ui/Button"; 2 + import type { PlaceholderFormProps } from "./types"; 3 + 4 + export const PlaceholderForm = ({ title, onBack }: PlaceholderFormProps) => ( 5 + <div className="space-y-4"> 6 + <h2 className="text-xl font-semibold text-ctp-text">{title}</h2> 7 + <p className="text-ctp-subtext0">This feature is coming soon!</p> 8 + <Button onClick={onBack}>Back to Options</Button> 9 + </div> 10 + );
+88
apps/client/src/features/vacancies/components/VacancyCreationSelector/VacancyCreationSelector.tsx
··· 1 + import { useState } from "react"; 2 + import Button from "@/ui/Button"; 3 + import { VacancyForm } from "../VacancyForm"; 4 + import { CreationMethodCard } from "./CreationMethodCard"; 5 + import { creationMethods } from "./constants"; 6 + import { PlaceholderForm } from "./PlaceholderForm"; 7 + import type { CreationMethod, VacancyCreationSelectorProps } from "./types"; 8 + 9 + export const VacancyCreationSelector = ({ 10 + onSuccess, 11 + onCancel, 12 + }: VacancyCreationSelectorProps) => { 13 + const [selectedMethod, setSelectedMethod] = useState<CreationMethod>(null); 14 + 15 + // Lookup table for method-specific components 16 + const methodComponents: Record< 17 + Exclude<CreationMethod, null>, 18 + React.ReactNode 19 + > = { 20 + form: ( 21 + <VacancyForm 22 + onSuccess={onSuccess || (() => {})} 23 + onCancel={() => setSelectedMethod(null)} 24 + /> 25 + ), 26 + file: ( 27 + <PlaceholderForm 28 + title="Create from File" 29 + onBack={() => setSelectedMethod(null)} 30 + /> 31 + ), 32 + url: ( 33 + <PlaceholderForm 34 + title="Create from URL" 35 + onBack={() => setSelectedMethod(null)} 36 + /> 37 + ), 38 + paste: ( 39 + <PlaceholderForm 40 + title="Create from Text" 41 + onBack={() => setSelectedMethod(null)} 42 + /> 43 + ), 44 + }; 45 + 46 + // If a method is selected, show the appropriate component 47 + if (selectedMethod) { 48 + return methodComponents[selectedMethod]; 49 + } 50 + 51 + // Show method selection 52 + return ( 53 + <div className="space-y-6"> 54 + <div className="text-center"> 55 + <h2 className="text-xl font-semibold text-ctp-text mb-2"> 56 + Create New Vacancy 57 + </h2> 58 + <p className="text-ctp-subtext0"> 59 + Choose how you'd like to create your vacancy 60 + </p> 61 + </div> 62 + 63 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 64 + {creationMethods.map((method) => { 65 + const IconComponent = method.icon; 66 + return ( 67 + <CreationMethodCard 68 + key={method.method} 69 + icon={<IconComponent />} 70 + title={method.title} 71 + description={method.description} 72 + color={method.color} 73 + onClick={() => setSelectedMethod(method.method)} 74 + /> 75 + ); 76 + })} 77 + </div> 78 + 79 + {onCancel && ( 80 + <div className="flex justify-end"> 81 + <Button variant="ghost" onClick={onCancel}> 82 + Cancel 83 + </Button> 84 + </div> 85 + )} 86 + </div> 87 + ); 88 + };
+41
apps/client/src/features/vacancies/components/VacancyCreationSelector/constants.ts
··· 1 + import { 2 + DocumentIcon, 3 + EditIcon, 4 + LinkIcon, 5 + UploadIcon, 6 + } from "@/components/icons"; 7 + import type { CreationMethod } from "./types"; 8 + 9 + export const creationMethods = [ 10 + { 11 + method: "form" as CreationMethod, 12 + icon: EditIcon, 13 + title: "Manual Form", 14 + description: "Fill out the vacancy details manually using our form", 15 + color: "blue" as const, 16 + }, 17 + { 18 + method: "file" as CreationMethod, 19 + icon: UploadIcon, 20 + title: "Upload File", 21 + description: "Upload a job posting file (PDF, DOC, TXT)", 22 + placeholderTitle: "Upload Vacancy File", 23 + color: "green" as const, 24 + }, 25 + { 26 + method: "url" as CreationMethod, 27 + icon: LinkIcon, 28 + title: "From URL", 29 + description: "Parse job posting from a web URL", 30 + placeholderTitle: "Import Vacancy from URL", 31 + color: "yellow" as const, 32 + }, 33 + { 34 + method: "paste" as CreationMethod, 35 + icon: DocumentIcon, 36 + title: "Paste Text", 37 + description: "Paste the full job posting text", 38 + placeholderTitle: "Paste Vacancy Text", 39 + color: "mauve" as const, 40 + }, 41 + ];
+9
apps/client/src/features/vacancies/components/VacancyCreationSelector/index.ts
··· 1 + export { CreationMethodCard } from "./CreationMethodCard"; 2 + export { PlaceholderForm } from "./PlaceholderForm"; 3 + export type { 4 + CreationMethod, 5 + CreationMethodCardProps, 6 + PlaceholderFormProps, 7 + VacancyCreationSelectorProps, 8 + } from "./types"; 9 + export { VacancyCreationSelector } from "./VacancyCreationSelector";
+19
apps/client/src/features/vacancies/components/VacancyCreationSelector/types.ts
··· 1 + export type CreationMethod = "form" | "file" | "url" | "paste" | null; 2 + 3 + export interface VacancyCreationSelectorProps { 4 + onSuccess?: () => void; 5 + onCancel?: () => void; 6 + } 7 + 8 + export interface CreationMethodCardProps { 9 + icon: React.ReactNode; 10 + title: string; 11 + description: string; 12 + color: "blue" | "green" | "yellow" | "mauve"; 13 + onClick: () => void; 14 + } 15 + 16 + export interface PlaceholderFormProps { 17 + title: string; 18 + onBack: () => void; 19 + }
+49
apps/client/src/features/vacancies/components/VacancyCreationSelector/variants.ts
··· 1 + import { cva } from "class-variance-authority"; 2 + 3 + export const creationMethodCardVariants = cva( 4 + "p-6 bg-ctp-surface0 border border-ctp-surface1 rounded-lg hover:bg-ctp-surface1 transition-colors text-left group", 5 + { 6 + variants: { 7 + color: { 8 + blue: "", 9 + green: "", 10 + yellow: "", 11 + mauve: "", 12 + }, 13 + }, 14 + defaultVariants: { 15 + color: "blue", 16 + }, 17 + }, 18 + ); 19 + 20 + export const iconContainerVariants = cva( 21 + "w-10 h-10 rounded-lg flex items-center justify-center transition-colors", 22 + { 23 + variants: { 24 + color: { 25 + blue: "bg-ctp-blue/20 group-hover:bg-ctp-blue/30", 26 + green: "bg-ctp-green/20 group-hover:bg-ctp-green/30", 27 + yellow: "bg-ctp-yellow/20 group-hover:bg-ctp-yellow/30", 28 + mauve: "bg-ctp-mauve/20 group-hover:bg-ctp-mauve/30", 29 + }, 30 + }, 31 + defaultVariants: { 32 + color: "blue", 33 + }, 34 + }, 35 + ); 36 + 37 + export const iconButtonVariants = cva("hover:bg-transparent", { 38 + variants: { 39 + color: { 40 + blue: "text-ctp-blue", 41 + green: "text-ctp-green", 42 + yellow: "text-ctp-yellow", 43 + mauve: "text-ctp-mauve", 44 + }, 45 + }, 46 + defaultVariants: { 47 + color: "blue", 48 + }, 49 + });
+143
apps/client/src/features/vacancies/components/VacancyList.tsx
··· 1 + import { DeleteIcon, EditIcon } from "@/components/icons"; 2 + import { useConfirmationModal } from "@/contexts/ConfirmationModalContext"; 3 + import { useToast } from "@/contexts/ToastContext"; 4 + import { useDeleteVacancyMutation } from "@/generated/graphql"; 5 + import IconButton from "@/ui/IconButton"; 6 + import { StatusBadge } from "@/ui/StatusBadge"; 7 + import { 8 + Table, 9 + TableBody, 10 + TableCell, 11 + TableHeader, 12 + TableHeaderCell, 13 + TableRow, 14 + } from "@/ui/Table"; 15 + import { formatDeadline, formatSimpleDate } from "@/utils/dateUtils"; 16 + 17 + interface VacancyListProps { 18 + vacancies: Array<{ 19 + id: string; 20 + title: string; 21 + company: string; 22 + description?: string | null; 23 + requirements?: string | null; 24 + location?: string | null; 25 + salary?: string | null; 26 + jobType?: string | null; 27 + applicationUrl?: string | null; 28 + deadline?: string | null; 29 + isActive: boolean; 30 + createdAt: string; 31 + }>; 32 + onDelete?: () => void; 33 + } 34 + 35 + export const VacancyList = ({ vacancies, onDelete }: VacancyListProps) => { 36 + const [deleteVacancy, { loading }] = useDeleteVacancyMutation(); 37 + const { showSuccess, showError } = useToast(); 38 + const { showConfirmation } = useConfirmationModal(); 39 + 40 + const handleDeleteClick = async (vacancyId: string) => { 41 + const vacancy = vacancies.find((v) => v.id === vacancyId); 42 + 43 + const _confirmed = await showConfirmation({ 44 + title: "Delete Vacancy", 45 + message: `Are you sure you want to delete the vacancy for ${vacancy?.title} at ${vacancy?.company}? This action cannot be undone.`, 46 + confirmText: "Delete", 47 + cancelText: "Cancel", 48 + variant: "danger", 49 + onConfirm: async () => { 50 + try { 51 + await deleteVacancy({ 52 + variables: { id: vacancyId }, 53 + }); 54 + 55 + showSuccess( 56 + "Vacancy Deleted", 57 + `Successfully deleted vacancy for ${vacancy?.title} at ${vacancy?.company}`, 58 + ); 59 + 60 + onDelete?.(); 61 + } catch (error) { 62 + console.error("Error deleting vacancy:", error); 63 + showError( 64 + "Failed to Delete Vacancy", 65 + "There was an error deleting the vacancy. Please try again.", 66 + ); 67 + } 68 + }, 69 + }); 70 + }; 71 + 72 + if (vacancies.length === 0) { 73 + return ( 74 + <div className="text-center py-8"> 75 + <div className="text-ctp-subtext0 mb-2">No vacancies found</div> 76 + <div className="text-sm text-ctp-subtext1"> 77 + Create your first job vacancy to get started 78 + </div> 79 + </div> 80 + ); 81 + } 82 + 83 + return ( 84 + <Table> 85 + <TableHeader> 86 + <TableRow> 87 + <TableHeaderCell>Job Title</TableHeaderCell> 88 + <TableHeaderCell>Location</TableHeaderCell> 89 + <TableHeaderCell>Salary</TableHeaderCell> 90 + <TableHeaderCell>Deadline</TableHeaderCell> 91 + <TableHeaderCell>Status</TableHeaderCell> 92 + <TableHeaderCell>Created</TableHeaderCell> 93 + <TableHeaderCell>Actions</TableHeaderCell> 94 + </TableRow> 95 + </TableHeader> 96 + <TableBody> 97 + {vacancies.map((vacancy) => ( 98 + <TableRow key={vacancy.id}> 99 + <TableCell> 100 + <div> 101 + <div className="font-medium text-ctp-text">{vacancy.title}</div> 102 + <div className="text-sm text-ctp-subtext0"> 103 + {vacancy.company} 104 + </div> 105 + </div> 106 + </TableCell> 107 + <TableCell>{vacancy.location || "—"}</TableCell> 108 + <TableCell>{vacancy.salary || "—"}</TableCell> 109 + <TableCell>{formatDeadline(vacancy.deadline)}</TableCell> 110 + <TableCell> 111 + <StatusBadge isActive={vacancy.isActive} /> 112 + </TableCell> 113 + <TableCell>{formatSimpleDate(vacancy.createdAt)}</TableCell> 114 + <TableCell> 115 + <div className="flex items-center gap-2"> 116 + <IconButton 117 + icon={<EditIcon />} 118 + label="Edit vacancy" 119 + variant="ghost" 120 + size="sm" 121 + className="text-ctp-blue hover:bg-ctp-blue/20" 122 + onClick={() => { 123 + // TODO: Implement edit functionality 124 + console.log("Edit vacancy:", vacancy.id); 125 + }} 126 + /> 127 + <IconButton 128 + icon={<DeleteIcon />} 129 + label="Delete vacancy" 130 + variant="ghost" 131 + size="sm" 132 + className="text-ctp-red hover:bg-ctp-red/20" 133 + onClick={() => handleDeleteClick(vacancy.id)} 134 + disabled={loading} 135 + /> 136 + </div> 137 + </TableCell> 138 + </TableRow> 139 + ))} 140 + </TableBody> 141 + </Table> 142 + ); 143 + };
+4
apps/client/src/features/vacancies/components/index.ts
··· 1 + export { VacancyCard } from "./VacancyCard"; 2 + export { VacancyCreationSelector } from "./VacancyCreationSelector"; 3 + export { VacancyForm } from "./VacancyForm"; 4 + export { VacancyList } from "./VacancyList";
+40
apps/client/src/features/vacancies/queries/create-vacancy.graphql
··· 1 + mutation CreateVacancy( 2 + $title: String! 3 + $company: String! 4 + $description: String 5 + $requirements: String 6 + $location: String 7 + $salary: String 8 + $jobType: String 9 + $applicationUrl: String 10 + $deadline: DateTime 11 + $isActive: Boolean 12 + ) { 13 + createVacancy( 14 + title: $title 15 + company: $company 16 + description: $description 17 + requirements: $requirements 18 + location: $location 19 + salary: $salary 20 + jobType: $jobType 21 + applicationUrl: $applicationUrl 22 + deadline: $deadline 23 + isActive: $isActive 24 + ) { 25 + id 26 + userId 27 + title 28 + company 29 + description 30 + requirements 31 + location 32 + salary 33 + jobType 34 + applicationUrl 35 + deadline 36 + isActive 37 + createdAt 38 + updatedAt 39 + } 40 + }
+3
apps/client/src/features/vacancies/queries/delete-vacancy.graphql
··· 1 + mutation DeleteVacancy($id: String!) { 2 + deleteVacancy(id: $id) 3 + }
+18
apps/client/src/features/vacancies/queries/my-vacancies.graphql
··· 1 + query MyVacancies { 2 + myVacancies { 3 + id 4 + userId 5 + title 6 + company 7 + description 8 + requirements 9 + location 10 + salary 11 + jobType 12 + applicationUrl 13 + deadline 14 + isActive 15 + createdAt 16 + updatedAt 17 + } 18 + }
apps/client/src/graphql/features/app/app.graphql apps/client/src/features/app/queries/app.graphql
apps/client/src/graphql/features/auth/auth.graphql apps/client/src/features/auth/queries/auth.graphql
apps/client/src/graphql/features/job-experience/delete-job-experience.graphql apps/client/src/features/job-experience/queries/delete-job-experience.graphql
apps/client/src/graphql/features/job-experience/me-job-experience.graphql apps/client/src/features/job-experience/queries/me-job-experience.graphql
apps/client/src/graphql/features/user/me-minimal.graphql apps/client/src/features/user/queries/me-minimal.graphql
apps/client/src/graphql/features/user/me-with-organizations.graphql apps/client/src/features/user/queries/me-with-organizations.graphql
-48
apps/client/src/graphql/features/user/me.graphql
··· 1 - query Me { 2 - me { 3 - id 4 - email 5 - name 6 - createdAt 7 - organizations { 8 - id 9 - name 10 - description 11 - createdAt 12 - updatedAt 13 - users { 14 - id 15 - joinedAt 16 - user { 17 - id 18 - name 19 - email 20 - createdAt 21 - experience { 22 - id 23 - startDate 24 - endDate 25 - description 26 - company { 27 - id 28 - name 29 - website 30 - } 31 - role { 32 - id 33 - name 34 - } 35 - level { 36 - id 37 - name 38 - } 39 - skills { 40 - id 41 - name 42 - } 43 - } 44 - } 45 - } 46 - } 47 - } 48 - }
+29 -18
apps/client/src/hooks/useServerHealth.ts
··· 1 - import { useRef, useEffect } from "react"; 2 - import { useHealthQuery } from "@/generated/graphql"; 1 + import { useEffect, useRef } from "react"; 3 2 import { useToast } from "@/contexts/ToastContext"; 3 + import { useHealthQuery } from "@/generated/graphql"; 4 4 5 5 type ServerHealthStatus = "checking" | "online" | "offline"; 6 6 ··· 13 13 const initialCheckRef = useRef(false); 14 14 const lastErrorToastRef = useRef<Date | null>(null); 15 15 const wasOfflineRef = useRef(false); 16 - 16 + 17 17 // Use Apollo's generated hook with polling 18 18 const healthQuery = useHealthQuery({ 19 19 fetchPolicy: "network-only", ··· 26 26 useEffect(() => { 27 27 const { data, error } = healthQuery; 28 28 const now = new Date(); 29 - 29 + 30 30 if (error && !data) { 31 31 // Only show error toast if: 32 32 // 1. It's not the initial check (to avoid showing error immediately on app load) 33 33 // 2. We haven't shown an error toast in the last 60 seconds (to avoid spam) 34 - const shouldShowError = initialCheckRef.current && 35 - (!lastErrorToastRef.current || 36 - (now.getTime() - lastErrorToastRef.current.getTime()) > 60000); 37 - 34 + const shouldShowError = 35 + initialCheckRef.current && 36 + (!lastErrorToastRef.current || 37 + now.getTime() - lastErrorToastRef.current.getTime() > 60000); 38 + 38 39 if (shouldShowError) { 39 40 lastErrorToastRef.current = now; 40 - const serverUrl = import.meta.env["VITE_SERVER_URL"] || "http://localhost:3000"; 41 - showError("Server Unreachable", `Cannot connect to ${serverUrl}. Check your connection and try again.`); 41 + const serverUrl = 42 + import.meta.env["VITE_SERVER_URL"] || "http://localhost:3000"; 43 + showError( 44 + "Server Unreachable", 45 + `Cannot connect to ${serverUrl}. Check your connection and try again.`, 46 + ); 42 47 } 43 - 48 + 44 49 wasOfflineRef.current = true; 45 50 } else if (data) { 46 51 // Show success toast only when recovering from offline state 47 52 if (wasOfflineRef.current) { 48 - const serverUrl = import.meta.env["VITE_SERVER_URL"] || "http://localhost:3000"; 49 - showSuccess("Server Connected", `Successfully connected to ${serverUrl}`); 53 + const serverUrl = 54 + import.meta.env["VITE_SERVER_URL"] || "http://localhost:3000"; 55 + showSuccess( 56 + "Server Connected", 57 + `Successfully connected to ${serverUrl}`, 58 + ); 50 59 } 51 - 60 + 52 61 wasOfflineRef.current = false; 53 62 initialCheckRef.current = true; 54 63 } 55 64 }, [healthQuery, showError, showSuccess]); 56 65 57 66 // Compute derived state from Apollo's data 58 - const status: ServerHealthStatus = 59 - healthQuery.loading && !healthQuery.data ? "checking" : 60 - healthQuery.error && !healthQuery.data ? "offline" : 61 - "online"; 67 + const status: ServerHealthStatus = 68 + healthQuery.loading && !healthQuery.data 69 + ? "checking" 70 + : healthQuery.error && !healthQuery.data 71 + ? "offline" 72 + : "online"; 62 73 63 74 return { 64 75 // Derived state for convenience
+8 -8
apps/client/src/lib/config.ts
··· 7 7 */ 8 8 export const getServerUrl = (): string => { 9 9 const serverUrl = import.meta.env["VITE_SERVER_URL"]; 10 - 10 + 11 11 if (!serverUrl) { 12 12 return "http://localhost:3000"; 13 13 } 14 - 14 + 15 15 // Remove trailing slash if present 16 - return serverUrl.endsWith('/') ? serverUrl.slice(0, -1) : serverUrl; 16 + return serverUrl.endsWith("/") ? serverUrl.slice(0, -1) : serverUrl; 17 17 }; 18 18 19 19 /** ··· 32 32 return { 33 33 protocol: urlObj.protocol.slice(0, -1), // Remove trailing ':' 34 34 hostname: urlObj.hostname, 35 - port: urlObj.port || (url.startsWith('https') ? '443' : '80'), 35 + port: urlObj.port || (url.startsWith("https") ? "443" : "80"), 36 36 url: url, 37 37 }; 38 38 } catch { 39 39 // Fallback for invalid URLs 40 40 return { 41 - protocol: 'http', 42 - hostname: 'localhost', 43 - port: '3000', 44 - url: 'http://localhost:3000', 41 + protocol: "http", 42 + hostname: "localhost", 43 + port: "3000", 44 + url: "http://localhost:3000", 45 45 }; 46 46 } 47 47 };
+34
apps/client/src/pages/CreateJobExperiencePage.tsx
··· 1 + import { useNavigate } from "react-router-dom"; 2 + import { JobExperienceCreationSelector } from "@/features/job-experience/components/JobExperienceCreationSelector"; 3 + 4 + export default function CreateJobExperiencePage() { 5 + const navigate = useNavigate(); 6 + 7 + const handleSuccess = () => { 8 + navigate("/job-experience"); 9 + }; 10 + 11 + const handleCancel = () => { 12 + navigate("/job-experience"); 13 + }; 14 + 15 + return ( 16 + <div className="space-y-6"> 17 + <div className="flex items-center justify-between"> 18 + <div> 19 + <h1 className="text-2xl font-bold text-ctp-text"> 20 + Create Job Experience 21 + </h1> 22 + <p className="text-ctp-subtext0 mt-1"> 23 + Add a new job experience to your profile 24 + </p> 25 + </div> 26 + </div> 27 + 28 + <JobExperienceCreationSelector 29 + onSuccess={handleSuccess} 30 + onCancel={handleCancel} 31 + /> 32 + </div> 33 + ); 34 + }
+40
apps/client/src/pages/CreateVacancyPage.tsx
··· 1 + import { useNavigate } from "react-router-dom"; 2 + import { VacancyCreationSelector } from "@/features/vacancies/components"; 3 + import Button from "@/ui/Button"; 4 + 5 + export default function CreateVacancyPage() { 6 + const navigate = useNavigate(); 7 + 8 + const handleSuccess = () => { 9 + navigate("/vacancies"); 10 + }; 11 + 12 + const handleCancel = () => { 13 + navigate("/vacancies"); 14 + }; 15 + 16 + return ( 17 + <div className="container mx-auto py-8 px-4 max-w-4xl"> 18 + <div className="mb-6"> 19 + <Button 20 + variant="ghost" 21 + onClick={() => navigate("/vacancies")} 22 + className="mb-4" 23 + > 24 + ← Back to Vacancies 25 + </Button> 26 + <h1 className="text-3xl font-bold text-ctp-text">Create New Vacancy</h1> 27 + <p className="text-ctp-subtext0 mt-2"> 28 + Choose how you'd like to create your vacancy listing 29 + </p> 30 + </div> 31 + 32 + <div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-6 shadow-sm"> 33 + <VacancyCreationSelector 34 + onSuccess={handleSuccess} 35 + onCancel={handleCancel} 36 + /> 37 + </div> 38 + </div> 39 + ); 40 + }
+50 -30
apps/client/src/pages/JobExperiencePage.tsx
··· 1 + import { useNavigate } from "react-router-dom"; 1 2 import { ErrorDisplay } from "@/components/ErrorBoundary"; 2 3 import { useToast } from "@/contexts/ToastContext"; 3 4 import { 4 - JobExperienceEmpty, 5 - JobExperienceHeader, 6 - JobExperienceList, 7 5 JobExperienceLoading, 6 + JobExperienceTable, 8 7 } from "@/features/job-experience/components"; 9 8 import type { MeJobExperienceQuery } from "@/generated/graphql"; 10 - import { 11 - useDeleteJobExperienceMutation, 12 - useMeJobExperienceQuery, 13 - } from "@/generated/graphql"; 9 + import { useMeJobExperienceQuery } from "@/generated/graphql"; 10 + import Button from "@/ui/Button"; 14 11 15 12 export default function JobExperiencePage() { 16 13 const { data, loading, error, refetch } = useMeJobExperienceQuery(); 17 - const [deleteJobExperience, { loading: deleteLoading }] = 18 - useDeleteJobExperienceMutation(); 19 - const { showSuccess, showError, showInfo } = useToast(); 14 + const { showInfo } = useToast(); 15 + const navigate = useNavigate(); 20 16 21 17 const handleEdit = ( 22 18 experience: MeJobExperienceQuery["myEmploymentHistory"][0], ··· 26 22 console.log("Edit experience:", experience); 27 23 }; 28 24 29 - const handleDelete = async (experienceId: string) => { 25 + const handleDelete = async () => { 30 26 try { 31 - await deleteJobExperience({ 32 - variables: { id: experienceId }, 33 - }); 34 - // Refetch the data to update the UI 35 27 await refetch(); 36 - showSuccess( 37 - "Job Experience Deleted", 38 - "The job experience has been successfully removed.", 39 - ); 40 28 } catch (error) { 41 - console.error("Failed to delete job experience:", error); 42 - showError( 43 - "Delete Failed", 44 - "Failed to delete the job experience. Please try again.", 45 - ); 29 + console.error("Error refreshing job experiences:", error); 46 30 } 47 31 }; 48 32 ··· 63 47 const jobExperiences = data?.myEmploymentHistory || []; 64 48 65 49 if (jobExperiences.length === 0) { 66 - return <JobExperienceEmpty />; 50 + return ( 51 + <div className="space-y-6"> 52 + <div className="flex items-center justify-between"> 53 + <div> 54 + <h1 className="text-2xl font-bold text-ctp-text">Job Experience</h1> 55 + <p className="text-ctp-subtext0 mt-1"> 56 + Manage your work experience and career history 57 + </p> 58 + </div> 59 + 60 + <Button onClick={() => navigate("/job-experience/create")}> 61 + Create Experience 62 + </Button> 63 + </div> 64 + 65 + <div className="text-center py-8"> 66 + <div className="text-ctp-subtext0 mb-2">No job experiences found</div> 67 + <div className="text-sm text-ctp-subtext1"> 68 + Create your first job experience to get started 69 + </div> 70 + </div> 71 + </div> 72 + ); 67 73 } 68 74 69 75 return ( 70 - <div className="min-h-screen bg-white"> 71 - <div className="max-w-4xl mx-auto px-4 py-8"> 72 - <JobExperienceHeader /> 73 - <JobExperienceList 76 + <div className="space-y-6"> 77 + <div className="flex items-center justify-between"> 78 + <div> 79 + <h1 className="text-2xl font-bold text-ctp-text">Job Experience</h1> 80 + <p className="text-ctp-subtext0 mt-1"> 81 + Manage your work experience and career history 82 + </p> 83 + </div> 84 + 85 + <Button onClick={() => navigate("/job-experience/create")}> 86 + Create Experience 87 + </Button> 88 + </div> 89 + 90 + <div> 91 + <h2 className="text-lg font-semibold text-ctp-text mb-4"> 92 + Your Experience ({jobExperiences.length}) 93 + </h2> 94 + <JobExperienceTable 74 95 experiences={jobExperiences} 75 96 onEdit={handleEdit} 76 97 onDelete={handleDelete} 77 - isDeleting={deleteLoading} 78 98 /> 79 99 </div> 80 100 </div>
+11 -4
apps/client/src/pages/OrganizationsPage.tsx
··· 1 - import { useMeWithOrganizationsQuery } from "@/generated/graphql"; 2 1 import { OrganizationMembersTable } from "@/features/organizations/components"; 2 + import { useMeWithOrganizationsQuery } from "@/generated/graphql"; 3 3 4 4 export default function OrganizationsPage() { 5 5 const { data, loading, error } = useMeWithOrganizationsQuery(); ··· 8 8 return ( 9 9 <div> 10 10 <h1 className="mb-6 text-2xl font-bold text-ctp-text">Organizations</h1> 11 - <div className="text-center text-ctp-subtext0">Loading organizations...</div> 11 + <div className="text-center text-ctp-subtext0"> 12 + Loading organizations... 13 + </div> 12 14 </div> 13 15 ); 14 16 } ··· 17 19 return ( 18 20 <div> 19 21 <h1 className="mb-6 text-2xl font-bold text-ctp-text">Organizations</h1> 20 - <div className="text-center text-ctp-red">Error loading organizations</div> 22 + <div className="text-center text-ctp-red"> 23 + Error loading organizations 24 + </div> 21 25 </div> 22 26 ); 23 27 } ··· 41 45 <h1 className="mb-6 text-2xl font-bold text-ctp-text">Organizations</h1> 42 46 <div className="space-y-6"> 43 47 {organizations.map((organization) => ( 44 - <OrganizationMembersTable key={organization.id} organization={organization} /> 48 + <OrganizationMembersTable 49 + key={organization.id} 50 + organization={organization} 51 + /> 45 52 ))} 46 53 </div> 47 54 </div>
+4 -4
apps/client/src/pages/ProfilePage.tsx
··· 80 80 <TextInput 81 81 label="Name" 82 82 value={formData.name} 83 - onChange={(e) => 84 - setFormData({ ...formData, name: e.target.value }) 83 + onChange={(value: string) => 84 + setFormData({ ...formData, name: value }) 85 85 } 86 86 /> 87 87 <TextInput 88 88 label="Email" 89 89 type="email" 90 90 value={formData.email} 91 - onChange={(e) => 92 - setFormData({ ...formData, email: e.target.value }) 91 + onChange={(value: string) => 92 + setFormData({ ...formData, email: value }) 93 93 } 94 94 /> 95 95 <div className="flex space-x-4">
+69
apps/client/src/pages/VacanciesPage.tsx
··· 1 + import { useNavigate } from "react-router-dom"; 2 + import { useToast } from "@/contexts/ToastContext"; 3 + import { VacancyList } from "@/features/vacancies/components"; 4 + import { useMyVacanciesQuery } from "@/generated/graphql"; 5 + import Button from "@/ui/Button"; 6 + 7 + export default function VacanciesPage() { 8 + const { data, loading, error, refetch } = useMyVacanciesQuery(); 9 + const { showError } = useToast(); 10 + const navigate = useNavigate(); 11 + 12 + const handleDelete = async () => { 13 + try { 14 + await refetch(); 15 + } catch (error) { 16 + console.error("Error refreshing vacancies:", error); 17 + showError( 18 + "Failed to Refresh", 19 + "There was an error refreshing the vacancy list. Please try again.", 20 + ); 21 + } 22 + }; 23 + 24 + if (loading) { 25 + return ( 26 + <div className="flex items-center justify-center py-8"> 27 + <div className="text-ctp-subtext0">Loading vacancies...</div> 28 + </div> 29 + ); 30 + } 31 + 32 + if (error) { 33 + return ( 34 + <div className="text-center py-8"> 35 + <div className="text-ctp-red mb-2">Error loading vacancies</div> 36 + <div className="text-sm text-ctp-subtext0">{error.message}</div> 37 + <Button onClick={() => refetch()} className="mt-4"> 38 + Try Again 39 + </Button> 40 + </div> 41 + ); 42 + } 43 + 44 + const vacancies = data?.myVacancies || []; 45 + 46 + return ( 47 + <div className="space-y-6"> 48 + <div className="flex items-center justify-between"> 49 + <div> 50 + <h1 className="text-2xl font-bold text-ctp-text">Job Vacancies</h1> 51 + <p className="text-ctp-subtext0 mt-1"> 52 + Manage your job vacancies and track applications 53 + </p> 54 + </div> 55 + 56 + <Button onClick={() => navigate("/vacancies/create")}> 57 + Create Vacancy 58 + </Button> 59 + </div> 60 + 61 + <div> 62 + <h2 className="text-lg font-semibold text-ctp-text mb-4"> 63 + Your Vacancies ({vacancies.length}) 64 + </h2> 65 + <VacancyList vacancies={vacancies} onDelete={handleDelete} /> 66 + </div> 67 + </div> 68 + ); 69 + }
-110
apps/client/src/providers/TokenProvider.tsx
··· 1 - import { useApolloClient } from "@apollo/client"; 2 - import { raise } from "@cv/utils"; 3 - import { 4 - createContext, 5 - type ReactNode, 6 - useContext, 7 - useEffect, 8 - useState, 9 - } from "react"; 10 - import { AUTH_TOKEN_KEY } from "@/constants/auth"; 11 - import { useMeMinimalQuery } from "@/generated/graphql"; 12 - 13 - // This file exports both a component (TokenProvider) and a hook (useToken) 14 - // Vite Fast Refresh works better when we're explicit about this 15 - 16 - interface TokenContextType { 17 - token: string | null; 18 - setToken: (token: string) => void; 19 - clearToken: () => void; 20 - isAuthenticated: boolean; 21 - isLoading: boolean; 22 - } 23 - 24 - const TokenContext = createContext<TokenContextType | null>(null); 25 - 26 - export function TokenProvider({ children }: { children: ReactNode }) { 27 - const [token, setTokenState] = useState<string | null>(null); 28 - const [isAuthenticated, setIsAuthenticated] = useState(false); 29 - const [isLoading, setIsLoading] = useState(true); 30 - const apolloClient = useApolloClient(); 31 - 32 - // Initialize token from localStorage on mount 33 - useEffect(() => { 34 - const storedToken = localStorage.getItem(AUTH_TOKEN_KEY); 35 - setTokenState(storedToken); 36 - }, []); 37 - 38 - // Use the me query to validate the token 39 - const { 40 - data: meData, 41 - loading: meLoading, 42 - error: meError, 43 - } = useMeMinimalQuery({ 44 - skip: !token, // Only run query if we have a token 45 - }); 46 - 47 - // Handle authentication state based on query results 48 - useEffect(() => { 49 - if (!token) { 50 - setIsLoading(false); 51 - setIsAuthenticated(false); 52 - return; 53 - } 54 - 55 - if (meLoading) { 56 - setIsLoading(true); 57 - return; 58 - } 59 - 60 - if (meData) { 61 - setIsAuthenticated(true); 62 - setIsLoading(false); 63 - return; 64 - } 65 - 66 - if (meError) { 67 - setIsAuthenticated(false); 68 - setIsLoading(false); 69 - // Clear invalid token 70 - localStorage.removeItem(AUTH_TOKEN_KEY); 71 - setTokenState(null); 72 - } 73 - }, [token, meData, meLoading, meError]); 74 - 75 - const setToken = (newToken: string) => { 76 - localStorage.setItem(AUTH_TOKEN_KEY, newToken); 77 - setTokenState(newToken); 78 - setIsLoading(true); 79 - }; 80 - 81 - const clearToken = () => { 82 - localStorage.removeItem(AUTH_TOKEN_KEY); 83 - setTokenState(null); 84 - setIsAuthenticated(false); 85 - // Clear Apollo cache to remove any cached user data 86 - apolloClient.clearStore(); 87 - }; 88 - 89 - return ( 90 - <TokenContext.Provider 91 - value={{ 92 - token, 93 - setToken, 94 - clearToken, 95 - isAuthenticated, 96 - isLoading, 97 - }} 98 - > 99 - {children} 100 - </TokenContext.Provider> 101 - ); 102 - } 103 - 104 - // Custom hook for accessing token context 105 - function useToken() { 106 - const context = useContext(TokenContext); 107 - return context ?? raise("useToken must be used within a TokenProvider"); 108 - } 109 - 110 - export { useToken };
+9 -7
apps/client/src/ui/Badge.tsx
··· 1 1 import { cva, type VariantProps } from "class-variance-authority"; 2 2 import type { ReactNode } from "react"; 3 3 4 - export type CatppuccinColor = 4 + export type CatppuccinColor = 5 5 | "ctp-red" 6 6 | "ctp-orange" 7 7 | "ctp-yellow" ··· 24 24 variants: { 25 25 color: { 26 26 "ctp-red": "bg-ctp-red", 27 - "ctp-orange": "bg-ctp-orange", 27 + "ctp-orange": "bg-ctp-orange", 28 28 "ctp-yellow": "bg-ctp-yellow", 29 29 "ctp-green": "bg-ctp-green", 30 30 "ctp-teal": "bg-ctp-teal", ··· 49 49 type BadgeProps = { 50 50 children: ReactNode; 51 51 color?: CatppuccinColor; 52 - } & Omit<VariantProps<typeof badgeVariants>, 'color'>; 52 + } & Omit<VariantProps<typeof badgeVariants>, "color">; 53 53 54 - export default function Badge({ children, color, className }: BadgeProps & { className?: string }) { 54 + export default function Badge({ 55 + children, 56 + color, 57 + className, 58 + }: BadgeProps & { className?: string }) { 55 59 return ( 56 - <span className={badgeVariants({ color, className })}> 57 - {children} 58 - </span> 60 + <span className={badgeVariants({ color, className })}>{children}</span> 59 61 ); 60 62 }
+5 -5
apps/client/src/ui/Button.tsx
··· 30 30 ); 31 31 32 32 type ButtonProps = PropsWithChildren< 33 - ButtonHTMLAttributes<HTMLButtonElement> & 34 - VariantProps<typeof buttonVariants> & { 35 - leftIcon?: ReactNode; 36 - rightIcon?: ReactNode; 37 - } 33 + ButtonHTMLAttributes<HTMLButtonElement> & 34 + VariantProps<typeof buttonVariants> & { 35 + leftIcon?: ReactNode; 36 + rightIcon?: ReactNode; 37 + } 38 38 >; 39 39 40 40 export default function Button({
+64
apps/client/src/ui/Checkbox.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import type { InputHTMLAttributes } from "react"; 3 + 4 + const checkboxVariants = cva( 5 + "h-4 w-4 rounded border border-ctp-surface1 bg-ctp-surface0 text-ctp-mauve focus:ring-2 focus:ring-ctp-mauve focus:ring-offset-1 transition-colors", 6 + { 7 + variants: { 8 + state: { 9 + default: "", 10 + error: "border-ctp-red focus:ring-ctp-red", 11 + success: "border-ctp-green focus:ring-ctp-green", 12 + }, 13 + }, 14 + defaultVariants: { 15 + state: "default", 16 + }, 17 + }, 18 + ); 19 + 20 + type CheckboxProps = { 21 + label: string; 22 + error?: string; 23 + onChange?: (checked: boolean) => void; 24 + } & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> & 25 + VariantProps<typeof checkboxVariants>; 26 + 27 + export default function Checkbox({ 28 + label, 29 + className = "", 30 + error, 31 + onChange, 32 + id, 33 + ...props 34 + }: CheckboxProps) { 35 + const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { 36 + onChange?.(e.target.checked); 37 + }; 38 + 39 + const inputId = id || `checkbox-${Math.random().toString(36).substr(2, 9)}`; 40 + 41 + return ( 42 + <div className="space-y-1"> 43 + <div className="flex items-center"> 44 + <input 45 + type="checkbox" 46 + id={inputId} 47 + className={checkboxVariants({ 48 + state: error ? "error" : "default", 49 + className, 50 + })} 51 + onChange={handleChange} 52 + {...props} 53 + /> 54 + <label 55 + htmlFor={inputId} 56 + className="ml-2 block text-sm font-medium text-ctp-text" 57 + > 58 + {label} 59 + </label> 60 + </div> 61 + {error && <p className="text-xs text-ctp-red">{error}</p>} 62 + </div> 63 + ); 64 + }
+3 -3
apps/client/src/ui/IconButton.tsx
··· 29 29 }, 30 30 ); 31 31 32 - type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & 32 + type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & 33 33 VariantProps<typeof iconButtonVariants> & { 34 34 icon: ReactNode; 35 35 label: string; ··· 47 47 ...props 48 48 }: IconButtonProps) { 49 49 return ( 50 - <button 51 - className={iconButtonVariants({ variant, size, className })} 50 + <button 51 + className={iconButtonVariants({ variant, size, className })} 52 52 title={label} 53 53 aria-label={label} 54 54 {...props}
+86
apps/client/src/ui/Select.tsx
··· 1 + import { forwardRef } from "react"; 2 + 3 + interface SelectOption { 4 + value: string; 5 + label: string; 6 + } 7 + 8 + interface SelectProps { 9 + id?: string; 10 + label?: string; 11 + options: SelectOption[]; 12 + value: string; 13 + onChange: (value: string) => void; 14 + placeholder?: string; 15 + required?: boolean; 16 + disabled?: boolean; 17 + className?: string; 18 + error?: string | undefined; 19 + } 20 + 21 + export const Select = forwardRef<HTMLSelectElement, SelectProps>( 22 + ( 23 + { 24 + id, 25 + label, 26 + options, 27 + value, 28 + onChange, 29 + placeholder, 30 + required = false, 31 + disabled = false, 32 + className = "", 33 + error, 34 + }, 35 + ref, 36 + ) => { 37 + const inputId = id || `select-${Math.random().toString(36).substr(2, 9)}`; 38 + 39 + return ( 40 + <div className={`space-y-2 ${className}`}> 41 + {label && ( 42 + <label 43 + htmlFor={inputId} 44 + className="block text-sm font-medium text-ctp-text" 45 + > 46 + {label} 47 + {required && <span className="text-ctp-red ml-1">*</span>} 48 + </label> 49 + )} 50 + 51 + <select 52 + ref={ref} 53 + id={inputId} 54 + value={value} 55 + onChange={(e) => onChange(e.target.value)} 56 + disabled={disabled} 57 + className={` 58 + w-full px-3 py-2 border rounded-md shadow-sm bg-ctp-base text-ctp-text 59 + focus:outline-none focus:ring-2 focus:ring-ctp-blue focus:border-transparent 60 + disabled:bg-ctp-surface1 disabled:text-ctp-subtext1 disabled:cursor-not-allowed 61 + ${ 62 + error 63 + ? "border-ctp-red focus:ring-ctp-red" 64 + : "border-ctp-surface1 focus:border-ctp-blue" 65 + } 66 + `} 67 + > 68 + {placeholder && ( 69 + <option value="" disabled> 70 + {placeholder} 71 + </option> 72 + )} 73 + {options.map((option) => ( 74 + <option key={option.value} value={option.value}> 75 + {option.label} 76 + </option> 77 + ))} 78 + </select> 79 + 80 + {error && <p className="text-sm text-ctp-red">{error}</p>} 81 + </div> 82 + ); 83 + }, 84 + ); 85 + 86 + Select.displayName = "Select";
+35
apps/client/src/ui/StatusBadge.tsx
··· 1 + import { cva } from "class-variance-authority"; 2 + 3 + const statusBadgeVariants = cva("px-2 py-1 text-xs rounded-full font-medium", { 4 + variants: { 5 + status: { 6 + active: "bg-ctp-green text-white", 7 + inactive: "bg-ctp-red text-white", 8 + }, 9 + }, 10 + defaultVariants: { 11 + status: "inactive", 12 + }, 13 + }); 14 + 15 + interface StatusBadgeProps { 16 + isActive: boolean; 17 + activeLabel?: string; 18 + inactiveLabel?: string; 19 + className?: string; 20 + } 21 + 22 + export const StatusBadge = ({ 23 + isActive, 24 + activeLabel = "Active", 25 + inactiveLabel = "Inactive", 26 + className = "", 27 + }: StatusBadgeProps) => { 28 + return ( 29 + <span 30 + className={`${statusBadgeVariants({ status: isActive ? "active" : "inactive" })} ${className}`} 31 + > 32 + {isActive ? activeLabel : inactiveLabel} 33 + </span> 34 + ); 35 + };
+22 -12
apps/client/src/ui/Table.tsx
··· 35 35 36 36 export const Table = ({ children, className = "" }: TableProps) => { 37 37 return ( 38 - <div className={`overflow-hidden rounded-lg border border-ctp-surface1 ${className}`}> 38 + <div 39 + className={`overflow-hidden rounded-lg border border-ctp-surface1 ${className}`} 40 + > 39 41 <table className="min-w-full divide-y divide-ctp-surface1"> 40 42 {children} 41 43 </table> ··· 44 46 }; 45 47 46 48 export const TableHeader = ({ children, className = "" }: TableHeaderProps) => { 47 - return ( 48 - <thead className={`bg-ctp-surface1 ${className}`}> 49 - {children} 50 - </thead> 51 - ); 49 + return <thead className={`bg-ctp-surface1 ${className}`}>{children}</thead>; 52 50 }; 53 51 54 52 export const TableBody = ({ children, className = "" }: TableBodyProps) => { ··· 59 57 ); 60 58 }; 61 59 62 - export const TableRow = ({ children, className = "", onClick }: TableRowProps) => { 60 + export const TableRow = ({ 61 + children, 62 + className = "", 63 + onClick, 64 + }: TableRowProps) => { 63 65 return ( 64 - <tr 66 + <tr 65 67 className={`${onClick ? "cursor-pointer hover:bg-ctp-surface1/50" : ""} ${className}`} 66 68 onClick={onClick} 67 69 > ··· 70 72 ); 71 73 }; 72 74 73 - export const TableHeaderCell = ({ children, className = "", colSpan }: TableHeaderCellProps) => { 75 + export const TableHeaderCell = ({ 76 + children, 77 + className = "", 78 + colSpan, 79 + }: TableHeaderCellProps) => { 74 80 return ( 75 - <th 81 + <th 76 82 className={`px-6 py-4 text-left text-xs font-medium text-ctp-subtext0 uppercase tracking-wider ${className}`} 77 83 colSpan={colSpan} 78 84 > ··· 81 87 ); 82 88 }; 83 89 84 - export const TableCell = ({ children, className = "", colSpan }: TableCellProps) => { 90 + export const TableCell = ({ 91 + children, 92 + className = "", 93 + colSpan, 94 + }: TableCellProps) => { 85 95 return ( 86 - <td 96 + <td 87 97 className={`px-6 py-4 text-sm text-ctp-text ${className}`} 88 98 colSpan={colSpan} 89 99 >
+6 -3
apps/client/src/ui/TextInput.tsx
··· 25 25 26 26 type TextInputProps = { 27 27 label: string; 28 - error?: string; 28 + error?: string | undefined; 29 29 onChange?: (value: string) => void; 30 - } & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & 30 + } & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> & 31 31 VariantProps<typeof textInputVariants>; 32 32 33 33 export default function TextInput({ ··· 48 48 49 49 return ( 50 50 <div className="space-y-1"> 51 - <label htmlFor={inputId} className="block text-sm font-medium text-ctp-text"> 51 + <label 52 + htmlFor={inputId} 53 + className="block text-sm font-medium text-ctp-text" 54 + > 52 55 {label} 53 56 </label> 54 57 <input
+70
apps/client/src/ui/Textarea.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import type { TextareaHTMLAttributes } from "react"; 3 + 4 + const textareaVariants = cva( 5 + "w-full rounded bg-ctp-surface0 border border-ctp-surface1 p-2 text-ctp-text placeholder-ctp-subtext0 outline-none transition-colors focus:ring-2 focus:ring-ctp-mauve focus:ring-offset-1 focus:border-transparent", 6 + { 7 + variants: { 8 + size: { 9 + sm: "px-2 py-1 text-xs", 10 + md: "px-3 py-2 text-sm", 11 + lg: "px-4 py-3 text-base", 12 + }, 13 + state: { 14 + default: "", 15 + error: "border-ctp-red focus:ring-ctp-red", 16 + success: "border-ctp-green focus:ring-ctp-green", 17 + }, 18 + }, 19 + defaultVariants: { 20 + size: "md", 21 + state: "default", 22 + }, 23 + }, 24 + ); 25 + 26 + type TextareaProps = { 27 + label: string; 28 + error?: string; 29 + onChange?: (value: string) => void; 30 + } & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange"> & 31 + VariantProps<typeof textareaVariants>; 32 + 33 + export default function Textarea({ 34 + label, 35 + className = "", 36 + size, 37 + state, 38 + error, 39 + onChange, 40 + id, 41 + ...props 42 + }: TextareaProps) { 43 + const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { 44 + onChange?.(e.target.value); 45 + }; 46 + 47 + const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`; 48 + 49 + return ( 50 + <div className="space-y-1"> 51 + <label 52 + htmlFor={inputId} 53 + className="block text-sm font-medium text-ctp-text" 54 + > 55 + {label} 56 + </label> 57 + <textarea 58 + id={inputId} 59 + className={textareaVariants({ 60 + size, 61 + state: error ? "error" : state, 62 + className, 63 + })} 64 + onChange={handleChange} 65 + {...props} 66 + /> 67 + {error && <p className="text-xs text-ctp-red">{error}</p>} 68 + </div> 69 + ); 70 + }
+5 -7
apps/client/src/ui/index.ts
··· 1 1 // UI Components 2 2 export { default as Button } from "./Button"; 3 + export { default as Checkbox } from "./Checkbox"; 3 4 export { default as IconButton } from "./IconButton"; 4 - export { default as TextInput } from "./TextInput"; 5 - export { default as ConfirmationModal } from "./ConfirmationModal"; 6 - export { default as Toast } from "./Toast"; 7 - export { default as ToastContainer } from "./ToastContainer"; 5 + export { Select } from "./Select"; 6 + export { StatusBadge } from "./StatusBadge"; 8 7 export * from "./Table"; 9 - 10 - // Icons 11 - export * from "./icons"; 8 + export { default as Textarea } from "./Textarea"; 9 + export { default as TextInput } from "./TextInput";
+24 -3
apps/client/src/utils/dateUtils.ts
··· 53 53 */ 54 54 export function formatLastChecked(lastChecked: Date | null): string { 55 55 if (!lastChecked) return ""; 56 - 56 + 57 57 const now = new Date(); 58 58 const diffMs = now.getTime() - lastChecked.getTime(); 59 59 const diffSeconds = Math.floor(diffMs / 1000); 60 - 61 - return diffSeconds < 60 ? `${diffSeconds}s ago` : `${Math.floor(diffSeconds / 60)}m ago`; 60 + 61 + return diffSeconds < 60 62 + ? `${diffSeconds}s ago` 63 + : `${Math.floor(diffSeconds / 60)}m ago`; 64 + } 65 + 66 + /** 67 + * Formats a date string to a simple date format 68 + * @param dateString - ISO date string 69 + * @returns Formatted date string (e.g., "12/25/2023") 70 + */ 71 + export function formatSimpleDate(dateString: string): string { 72 + return new Date(dateString).toLocaleDateString(); 73 + } 74 + 75 + /** 76 + * Formats a date string for deadline display, handling null/undefined 77 + * @param deadlineString - ISO date string or null/undefined 78 + * @returns Formatted date string or "—" if null/undefined 79 + */ 80 + export function formatDeadline(deadlineString?: string | null): string { 81 + if (!deadlineString) return "—"; 82 + return new Date(deadlineString).toLocaleDateString(); 62 83 }
+1 -1
apps/client/tsconfig.json
··· 11 11 }, 12 12 "noUncheckedIndexedAccess": true, 13 13 "noPropertyAccessFromIndexSignature": true, 14 - "exactOptionalPropertyTypes": true, 14 + "exactOptionalPropertyTypes": false, 15 15 "noImplicitReturns": true, 16 16 "noImplicitThis": true, 17 17 "strictNullChecks": true,
+1 -1
apps/client/tsconfig.tsbuildinfo
··· 1 - {"root":["./src/App.tsx","./src/main.tsx","./src/components/ConfirmationModal.tsx","./src/components/ErrorBoundary.tsx","./src/components/ServerStatusIndicator.tsx","./src/components/Toast.tsx","./src/components/ToastContainer.tsx","./src/components/ServerStatusIndicator/ServerTooltip.tsx","./src/components/ServerStatusIndicator/StatusDot.tsx","./src/components/ServerStatusIndicator/constants.ts","./src/components/ServerStatusIndicator/index.tsx","./src/components/ServerStatusIndicator/types.ts","./src/components/ServerStatusIndicator/utils.ts","./src/components/icons/CloseIcon.tsx","./src/components/icons/DeleteIcon.tsx","./src/components/icons/EditIcon.tsx","./src/components/icons/ErrorIcon.tsx","./src/components/icons/LoadingIcon.tsx","./src/components/icons/ToastIcon.tsx","./src/components/icons/index.ts","./src/constants/auth.ts","./src/contexts/ToastContext.tsx","./src/features/auth/AuthForm.tsx","./src/features/auth/LoginForm.tsx","./src/features/auth/RegisterForm.tsx","./src/features/job-experience/components/JobExperienceCard.tsx","./src/features/job-experience/components/JobExperienceEmpty.tsx","./src/features/job-experience/components/JobExperienceHeader.tsx","./src/features/job-experience/components/JobExperienceList.tsx","./src/features/job-experience/components/JobExperienceLoading.tsx","./src/features/job-experience/components/index.ts","./src/features/organizations/components/MembersTableBody.tsx","./src/features/organizations/components/MembersTableHeader.tsx","./src/features/organizations/components/OrganizationMemberRow.tsx","./src/features/organizations/components/OrganizationMembersTable.tsx","./src/features/organizations/components/index.ts","./src/generated/graphql.ts","./src/hooks/useAuth.ts","./src/hooks/useServerHealth.ts","./src/layouts/AuthenticatedLayout.tsx","./src/lib/apollo-client.ts","./src/lib/config.ts","./src/pages/DashboardPage.tsx","./src/pages/JobExperiencePage.tsx","./src/pages/ProfilePage.tsx","./src/providers/TokenProvider.tsx","./src/router/AppRouter.tsx","./src/types/auth.ts","./src/types/graphql.d.ts","./src/ui/Button.tsx","./src/ui/IconButton.tsx","./src/ui/Table.tsx","./src/ui/TextInput.tsx","./src/ui/index.ts","./src/utils/auth.ts","./src/utils/dateUtils.ts"],"errors":true,"version":"5.9.3"} 1 + {"root":["./src/App.tsx","./src/main.tsx","./src/components/ConfirmationModal.tsx","./src/components/ErrorBoundary.tsx","./src/components/Navbar.tsx","./src/components/ServerStatusIndicator.tsx","./src/components/Toast.tsx","./src/components/ToastContainer.tsx","./src/components/navLinks.ts","./src/components/ServerStatusIndicator/ServerTooltip.tsx","./src/components/ServerStatusIndicator/StatusDot.tsx","./src/components/ServerStatusIndicator/constants.ts","./src/components/ServerStatusIndicator/index.tsx","./src/components/ServerStatusIndicator/types.ts","./src/components/ServerStatusIndicator/utils.ts","./src/components/icons/CloseIcon.tsx","./src/components/icons/DeleteIcon.tsx","./src/components/icons/DocumentIcon.tsx","./src/components/icons/EditIcon.tsx","./src/components/icons/ErrorIcon.tsx","./src/components/icons/LinkIcon.tsx","./src/components/icons/LoadingIcon.tsx","./src/components/icons/ToastIcon.tsx","./src/components/icons/UploadIcon.tsx","./src/components/icons/index.ts","./src/constants/auth.ts","./src/contexts/ConfirmationModalContext.tsx","./src/contexts/ToastContext.tsx","./src/features/auth/components/AuthForm.tsx","./src/features/auth/components/LoginForm.tsx","./src/features/auth/components/RegisterForm.tsx","./src/features/auth/components/index.ts","./src/features/job-experience/components/JobExperienceCard.tsx","./src/features/job-experience/components/JobExperienceCreationSelector.tsx","./src/features/job-experience/components/JobExperienceEmpty.tsx","./src/features/job-experience/components/JobExperienceForm.tsx","./src/features/job-experience/components/JobExperienceHeader.tsx","./src/features/job-experience/components/JobExperienceList.tsx","./src/features/job-experience/components/JobExperienceLoading.tsx","./src/features/job-experience/components/JobExperienceTable.tsx","./src/features/job-experience/components/index.ts","./src/features/organizations/components/MembersTableBody.tsx","./src/features/organizations/components/MembersTableHeader.tsx","./src/features/organizations/components/OrganizationMemberRow.tsx","./src/features/organizations/components/OrganizationMembersTable.tsx","./src/features/organizations/components/index.ts","./src/features/vacancies/components/VacancyCard.tsx","./src/features/vacancies/components/VacancyForm.tsx","./src/features/vacancies/components/VacancyList.tsx","./src/features/vacancies/components/index.ts","./src/features/vacancies/components/VacancyCreationSelector/CreationMethodCard.tsx","./src/features/vacancies/components/VacancyCreationSelector/PlaceholderForm.tsx","./src/features/vacancies/components/VacancyCreationSelector/VacancyCreationSelector.tsx","./src/features/vacancies/components/VacancyCreationSelector/constants.ts","./src/features/vacancies/components/VacancyCreationSelector/index.ts","./src/features/vacancies/components/VacancyCreationSelector/types.ts","./src/features/vacancies/components/VacancyCreationSelector/variants.ts","./src/generated/graphql.ts","./src/hooks/useAuth.ts","./src/hooks/useServerHealth.ts","./src/layouts/AuthenticatedLayout.tsx","./src/lib/apollo-client.ts","./src/lib/config.ts","./src/pages/CreateJobExperiencePage.tsx","./src/pages/CreateVacancyPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/JobExperiencePage.tsx","./src/pages/OrganizationsPage.tsx","./src/pages/ProfilePage.tsx","./src/pages/VacanciesPage.tsx","./src/providers/TokenProvider.tsx","./src/router/AppRouter.tsx","./src/types/auth.ts","./src/types/graphql.d.ts","./src/ui/Badge.tsx","./src/ui/Button.tsx","./src/ui/Checkbox.tsx","./src/ui/IconButton.tsx","./src/ui/Select.tsx","./src/ui/StatusBadge.tsx","./src/ui/Table.tsx","./src/ui/TextInput.tsx","./src/ui/Textarea.tsx","./src/ui/index.ts","./src/utils/auth.ts","./src/utils/dateUtils.ts","./src/validation/schemas.ts"],"version":"5.9.3"}
+22
apps/server/prisma/migrations/20251019214831_add_vacancies/migration.sql
··· 1 + -- CreateTable 2 + CREATE TABLE "vacancies" ( 3 + "id" TEXT NOT NULL, 4 + "userId" TEXT NOT NULL, 5 + "title" TEXT NOT NULL, 6 + "company" TEXT NOT NULL, 7 + "description" TEXT, 8 + "requirements" TEXT, 9 + "location" TEXT, 10 + "salary" TEXT, 11 + "jobType" TEXT, 12 + "applicationUrl" TEXT, 13 + "deadline" TIMESTAMP(3), 14 + "isActive" BOOLEAN NOT NULL DEFAULT true, 15 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 + "updatedAt" TIMESTAMP(3) NOT NULL, 17 + 18 + CONSTRAINT "vacancies_pkey" PRIMARY KEY ("id") 19 + ); 20 + 21 + -- AddForeignKey 22 + ALTER TABLE "vacancies" ADD CONSTRAINT "vacancies_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+29 -4
apps/server/prisma/schema.prisma
··· 22 22 // Job experiences 23 23 jobExperiences UserJobExperience[] 24 24 25 - // Organizations 26 - organizations UserOrganization[] 25 + // Organizations 26 + organizations UserOrganization[] 27 + 28 + // Vacancies 29 + vacancies Vacancy[] 27 30 28 - @@map("users") 29 - } 31 + @@map("users") 32 + } 30 33 31 34 model Skill { 32 35 id String @id @default(cuid()) ··· 148 151 @@unique([userId, organizationId]) 149 152 @@map("user_organizations") 150 153 } 154 + 155 + model Vacancy { 156 + id String @id @default(cuid()) 157 + userId String 158 + title String 159 + company String 160 + description String? 161 + requirements String? 162 + location String? 163 + salary String? 164 + jobType String? 165 + applicationUrl String? 166 + deadline DateTime? 167 + isActive Boolean @default(true) 168 + createdAt DateTime @default(now()) 169 + updatedAt DateTime @updatedAt 170 + 171 + // Relations 172 + user User @relation(fields: [userId], references: [id], onDelete: Cascade) 173 + 174 + @@map("vacancies") 175 + }
+14 -2
apps/server/src/main.ts
··· 1 + import { ConfigService } from "@nestjs/config"; 1 2 import { NestFactory } from "@nestjs/core"; 2 3 import { ExpressAdapter } from "@nestjs/platform-express"; 3 4 import { ZodValidationPipe } from "nestjs-zod"; 4 - import { ConfigService } from "@nestjs/config"; 5 5 import { AppModule } from "./modules/app.module"; 6 6 7 7 async function bootstrap(): Promise<void> { ··· 9 9 const configService = app.get(ConfigService); 10 10 11 11 // Enable CORS 12 + const configuredOrigin = configService.get<string>("CLIENT_ORIGIN"); 13 + const allowedOrigins = [ 14 + configuredOrigin, 15 + "http://localhost:5173", 16 + "http://127.0.0.1:5173", 17 + ].filter(Boolean) as string[]; 18 + 12 19 app.enableCors({ 13 - origin: true, // Allow all origins in development 20 + origin: (origin, callback) => { 21 + // Allow non-browser or same-origin requests with no Origin header 22 + if (!origin) return callback(null, true); 23 + if (allowedOrigins.includes(origin)) return callback(null, true); 24 + return callback(new Error("Not allowed by CORS"), false); 25 + }, 14 26 credentials: true, 15 27 methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], 16 28 allowedHeaders: ["Content-Type", "Authorization"],
+2
apps/server/src/modules/app.module.ts
··· 13 13 import { SkillModule } from "./job-experience/skill/skill.module"; 14 14 import { OrganizationModule } from "./organization/organization.module"; 15 15 import { SeedModule } from "./seed/seed.module"; 16 + import { VacancyModule } from "./vacancies/vacancy.module"; 16 17 17 18 @Module({ 18 19 imports: [ ··· 34 35 LevelModule, 35 36 EmploymentModule, 36 37 OrganizationModule, 38 + VacancyModule, 37 39 SeedModule, 38 40 ], 39 41 providers: [],
+1 -1
apps/server/src/modules/app/health.type.ts
··· 1 - import { ObjectType, Field } from "@nestjs/graphql"; 1 + import { Field, ObjectType } from "@nestjs/graphql"; 2 2 3 3 @ObjectType() 4 4 export class HealthResponse {
+8 -1
apps/server/src/modules/auth/auth.module.ts
··· 26 26 inject: [ConfigService], 27 27 }), 28 28 ], 29 - providers: [AuthService, UserService, AuthResolver, MeResolver, JwtAuthGuard, UserMapper], 29 + providers: [ 30 + AuthService, 31 + UserService, 32 + AuthResolver, 33 + MeResolver, 34 + JwtAuthGuard, 35 + UserMapper, 36 + ], 30 37 exports: [AuthService, UserService, JwtModule, JwtAuthGuard, UserMapper], 31 38 }) 32 39 export class AuthModule {}
+12 -4
apps/server/src/modules/auth/auth.service.ts
··· 1 - import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common"; 1 + import { 2 + ConflictException, 3 + Injectable, 4 + UnauthorizedException, 5 + } from "@nestjs/common"; 2 6 import { JwtService } from "@nestjs/jwt"; 3 7 import * as bcrypt from "bcryptjs"; 4 8 import type { AuthResponse, LoginDto, RegisterDto } from "./auth.dto"; ··· 12 16 private jwtService: JwtService, 13 17 ) {} 14 18 15 - async register({ email, name, password }: RegisterDto): Promise<AuthResponse> { 19 + async register({ 20 + email, 21 + name, 22 + password, 23 + }: RegisterDto): Promise<AuthResponse> { 16 24 if (await this.userService.exists(email)) { 17 25 throw new ConflictException("User with this email already exists"); 18 26 } ··· 38 46 async login({ email, password }: LoginDto): Promise<AuthResponse> { 39 47 try { 40 48 const passwordHash = await this.userService.getPasswordHash(email); 41 - 42 - if (!passwordHash || !(await bcrypt.compare(password, passwordHash))) { 49 + 50 + if (!(passwordHash && (await bcrypt.compare(password, passwordHash)))) { 43 51 throw new UnauthorizedException("Invalid credentials"); 44 52 } 45 53
+4 -1
apps/server/src/modules/auth/auth.type.ts
··· 14 14 this.user = user; 15 15 } 16 16 17 - static fromDomain(domainAuth: { access_token: string; user: User }): AuthResponse { 17 + static fromDomain(domainAuth: { 18 + access_token: string; 19 + user: User; 20 + }): AuthResponse { 18 21 return new AuthResponse(domainAuth.access_token, domainAuth.user); 19 22 } 20 23 }
+7 -5
apps/server/src/modules/auth/current-user.decorator.ts
··· 2 2 import { createParamDecorator, type ExecutionContext } from "@nestjs/common"; 3 3 import { GqlExecutionContext } from "@nestjs/graphql"; 4 4 5 - export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext) => { 6 - const ctx = GqlExecutionContext.create(context); 7 - const request = ctx.getContext().req; 8 - return request.user ?? raise("User not found in request context"); 9 - }); 5 + export const CurrentUser = createParamDecorator( 6 + (_data: unknown, context: ExecutionContext) => { 7 + const ctx = GqlExecutionContext.create(context); 8 + const request = ctx.getContext().req; 9 + return request.user ?? raise("User not found in request context"); 10 + }, 11 + );
+1 -3
apps/server/src/modules/auth/me.resolver.ts
··· 8 8 @Resolver(() => User) 9 9 @UseGuards(JwtAuthGuard) 10 10 export class MeResolver { 11 - constructor( 12 - private readonly userService: UserService, 13 - ) {} 11 + constructor(private readonly userService: UserService) {} 14 12 15 13 @Query(() => User) 16 14 async me(@CurrentUser() user: User): Promise<User> {
+19 -8
apps/server/src/modules/auth/user.type.ts
··· 1 1 import { Field, ID, ObjectType } from "@nestjs/graphql"; 2 - import { Organization } from "../organization/organization.entity"; 3 2 import { UserJobExperience } from "../job-experience/employment/user-job-experience.type"; 3 + import { Organization } from "../organization/organization.entity"; 4 4 import type { User as DomainUser } from "./user.entity"; 5 5 6 6 @ObjectType() ··· 24 24 experience?: UserJobExperience[] | undefined; 25 25 26 26 constructor( 27 - id: string, 28 - email: string, 29 - name: string, 30 - createdAt: Date, 27 + id: string, 28 + email: string, 29 + name: string, 30 + createdAt: Date, 31 31 organizations?: Organization[] | undefined, 32 - experience?: UserJobExperience[] | undefined 32 + experience?: UserJobExperience[] | undefined, 33 33 ) { 34 34 this.id = id; 35 35 this.email = email; ··· 39 39 this.experience = experience ?? undefined; 40 40 } 41 41 42 - static fromDomain(domainUser: DomainUser, organizations?: Organization[], experience?: UserJobExperience[]): User { 43 - return new User(domainUser.id, domainUser.email, domainUser.name, domainUser.createdAt, organizations, experience); 42 + static fromDomain( 43 + domainUser: DomainUser, 44 + organizations?: Organization[], 45 + experience?: UserJobExperience[], 46 + ): User { 47 + return new User( 48 + domainUser.id, 49 + domainUser.email, 50 + domainUser.name, 51 + domainUser.createdAt, 52 + organizations, 53 + experience, 54 + ); 44 55 } 45 56 }
+1 -1
apps/server/src/modules/base/base.module.ts
··· 5 5 providers: [], 6 6 exports: [], 7 7 }) 8 - export class BaseModule {} 8 + export class BaseModule {}
+1 -1
apps/server/src/modules/base/mapper.interface.ts
··· 6 6 * Maps a single Prisma entity to a domain entity 7 7 */ 8 8 toDomain(prismaEntity: TPrismaEntity): TDomainEntity; 9 - 9 + 10 10 /** 11 11 * Maps a single Prisma entity to a domain entity, handling null input 12 12 */
+1 -1
apps/server/src/modules/database/prisma.service.ts
··· 11 11 extends PrismaClient 12 12 implements OnModuleInit, OnModuleDestroy 13 13 { 14 - constructor(private readonly configService: ConfigService) { 14 + constructor(readonly configService: ConfigService) { 15 15 super({ 16 16 datasources: { 17 17 db: {
+14 -3
apps/server/src/modules/job-experience/employment/employment.module.ts
··· 1 1 import { Module } from "@nestjs/common"; 2 - import { CompanyModule } from "../company/company.module"; 3 2 import { DatabaseModule } from "../../database/database.module"; 3 + import { CompanyModule } from "../company/company.module"; 4 4 import { LevelModule } from "../level/level.module"; 5 5 import { RoleModule } from "../role/role.module"; 6 6 import { SkillModule } from "../skill/skill.module"; ··· 10 10 import { UserJobExperienceService } from "./user-job-experience.service"; 11 11 12 12 @Module({ 13 - imports: [DatabaseModule, CompanyModule, RoleModule, LevelModule, SkillModule], 14 - providers: [UserJobExperienceService, EmploymentResolver, UserFieldResolver, UserJobExperienceMapper], 13 + imports: [ 14 + DatabaseModule, 15 + CompanyModule, 16 + RoleModule, 17 + LevelModule, 18 + SkillModule, 19 + ], 20 + providers: [ 21 + UserJobExperienceService, 22 + EmploymentResolver, 23 + UserFieldResolver, 24 + UserJobExperienceMapper, 25 + ], 15 26 exports: [UserJobExperienceService, UserJobExperienceMapper], 16 27 }) 17 28 export class EmploymentModule {}
+6 -4
apps/server/src/modules/job-experience/employment/employment.resolver.ts
··· 50 50 const company = await this.companyService.findByIdOrFail(companyId); 51 51 const role = await this.roleService.findByIdOrFail(roleId); 52 52 const level = await this.levelService.findByIdOrFail(levelId); 53 - const skills = skillIds ? await Promise.all( 54 - skillIds.map(id => this.skillService.findByIdOrFail(id)) 55 - ) : undefined; 53 + const skills = skillIds 54 + ? await Promise.all( 55 + skillIds.map((id) => this.skillService.findByIdOrFail(id)), 56 + ) 57 + : undefined; 56 58 57 59 const createData: CreateUserJobExperienceDto = { 58 60 user, ··· 112 114 } 113 115 if (skillIds !== undefined) { 114 116 updateData.skills = await Promise.all( 115 - skillIds.map(id => this.skillService.findByIdOrFail(id)) 117 + skillIds.map((id) => this.skillService.findByIdOrFail(id)), 116 118 ); 117 119 } 118 120
+8 -5
apps/server/src/modules/job-experience/employment/user-field.resolver.ts
··· 1 - import { ResolveField, Parent, Resolver } from "@nestjs/graphql"; 1 + import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; 2 + import { User } from "../../auth/user.type"; 3 + import { UserJobExperienceService } from "./user-job-experience.service"; 2 4 import { UserJobExperience } from "./user-job-experience.type"; 3 - import { UserJobExperienceService } from "./user-job-experience.service"; 4 - import { User } from "../../auth/user.type"; 5 5 6 6 @Resolver(() => User) 7 7 export class UserFieldResolver { 8 - constructor(private readonly userJobExperienceService: UserJobExperienceService) {} 8 + constructor( 9 + private readonly userJobExperienceService: UserJobExperienceService, 10 + ) {} 9 11 10 12 @ResolveField(() => [UserJobExperience], { nullable: true }) 11 13 async experience(@Parent() user: User): Promise<UserJobExperience[]> { 12 - const domainExperiences = await this.userJobExperienceService.findForUser(user); 14 + const domainExperiences = 15 + await this.userJobExperienceService.findForUser(user); 13 16 return domainExperiences.map((exp) => UserJobExperience.fromDomain(exp)); 14 17 } 15 18 }
+22 -13
apps/server/src/modules/job-experience/employment/user-job-experience.mapper.ts
··· 1 1 import { Injectable } from "@nestjs/common"; 2 - import type { 3 - UserJobExperience as PrismaUserJobExperience, 2 + import type { 4 3 Company as PrismaCompany, 5 - Role as PrismaRole, 6 4 Level as PrismaLevel, 5 + Role as PrismaRole, 7 6 Skill as PrismaSkill, 7 + UserJobExperience as PrismaUserJobExperience, 8 8 } from "@prisma/client"; 9 9 import type { BaseMapper } from "../../base/mapper.interface"; 10 10 import { CompanyMapper } from "../company/company.mapper"; ··· 27 27 * Mapper service for converting between Prisma UserJobExperience entities and domain UserJobExperience entities 28 28 */ 29 29 @Injectable() 30 - export class UserJobExperienceMapper implements BaseMapper< 31 - PrismaUserJobExperienceWithRelations, 32 - UserJobExperience 33 - > { 30 + export class UserJobExperienceMapper 31 + implements BaseMapper<PrismaUserJobExperienceWithRelations, UserJobExperience> 32 + { 34 33 constructor( 35 34 private companyMapper: CompanyMapper, 36 35 private roleMapper: RoleMapper, ··· 42 41 * Uses overloads to return the correct type based on input 43 42 */ 44 43 toDomain(prismaExperience: null): null; 45 - toDomain(prismaExperience: PrismaUserJobExperienceWithRelations): UserJobExperience; 46 - toDomain(prismaExperience: PrismaUserJobExperienceWithRelations | null): UserJobExperience | null; 47 - toDomain(prismaExperience: PrismaUserJobExperienceWithRelations | null): UserJobExperience | null { 44 + toDomain( 45 + prismaExperience: PrismaUserJobExperienceWithRelations, 46 + ): UserJobExperience; 47 + toDomain( 48 + prismaExperience: PrismaUserJobExperienceWithRelations | null, 49 + ): UserJobExperience | null; 50 + toDomain( 51 + prismaExperience: PrismaUserJobExperienceWithRelations | null, 52 + ): UserJobExperience | null { 48 53 if (prismaExperience === null) { 49 54 return null; 50 55 } 51 - 56 + 52 57 return new UserJobExperience( 53 58 prismaExperience.id, 54 59 prismaExperience.startDate, ··· 59 64 this.levelMapper.toDomain(prismaExperience.level), 60 65 prismaExperience.endDate ?? undefined, 61 66 prismaExperience.description ?? undefined, 62 - prismaExperience.skills ? this.skillMapper.mapToDomain(prismaExperience.skills) : undefined, 67 + prismaExperience.skills 68 + ? this.skillMapper.mapToDomain(prismaExperience.skills) 69 + : undefined, 63 70 ); 64 71 } 65 72 66 73 /** 67 74 * Maps an array of Prisma UserJobExperience entities to domain UserJobExperience entities 68 75 */ 69 - mapToDomain(prismaExperiences: PrismaUserJobExperienceWithRelations[]): UserJobExperience[] { 76 + mapToDomain( 77 + prismaExperiences: PrismaUserJobExperienceWithRelations[], 78 + ): UserJobExperience[] { 70 79 return prismaExperiences.map((experience) => this.toDomain(experience)); 71 80 } 72 81 }
+6 -3
apps/server/src/modules/job-experience/employment/user-job-experience.service.ts
··· 40 40 private userJobExperienceMapper: UserJobExperienceMapper, 41 41 ) {} 42 42 43 - 44 43 async create( 45 44 createUserJobExperienceDto: CreateUserJobExperienceDto, 46 45 ): Promise<UserJobExperience> { ··· 60 59 } 61 60 if (createUserJobExperienceDto.skills !== undefined) { 62 61 createData.skills = { 63 - connect: createUserJobExperienceDto.skills.map((skill) => ({ id: skill.id })), 62 + connect: createUserJobExperienceDto.skills.map((skill) => ({ 63 + id: skill.id, 64 + })), 64 65 }; 65 66 } 66 67 ··· 147 148 } 148 149 if (updateUserJobExperienceDto.skills !== undefined) { 149 150 updateData.skills = { 150 - set: updateUserJobExperienceDto.skills.map((skill) => ({ id: skill.id })), 151 + set: updateUserJobExperienceDto.skills.map((skill) => ({ 152 + id: skill.id, 153 + })), 151 154 }; 152 155 } 153 156
+3 -1
apps/server/src/modules/job-experience/employment/user-job-experience.type.ts
··· 65 65 } 66 66 } 67 67 68 - static fromDomain(domainExperience: DomainUserJobExperience): UserJobExperience { 68 + static fromDomain( 69 + domainExperience: DomainUserJobExperience, 70 + ): UserJobExperience { 69 71 return new UserJobExperience( 70 72 domainExperience.id, 71 73 Company.fromDomain(domainExperience.company),
+28
apps/server/src/modules/organization/organization-role.mapper.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import type { OrganizationRole as PrismaOrganizationRole } from "@prisma/client"; 3 + import type { BaseMapper } from "../base/mapper.interface"; 4 + import { OrganizationRole } from "./organization-role.entity"; 5 + 6 + @Injectable() 7 + export class OrganizationRoleMapper 8 + implements BaseMapper<PrismaOrganizationRole, OrganizationRole> 9 + { 10 + toDomain(prismaRole: null): null; 11 + toDomain(prismaRole: PrismaOrganizationRole): OrganizationRole; 12 + toDomain(prismaRole: PrismaOrganizationRole | null): OrganizationRole | null; 13 + toDomain(prismaRole: PrismaOrganizationRole | null): OrganizationRole | null { 14 + if (prismaRole === null) return null; 15 + return new OrganizationRole({ 16 + id: prismaRole.id, 17 + name: prismaRole.name, 18 + description: prismaRole.description, 19 + color: prismaRole.color, 20 + createdAt: prismaRole.createdAt, 21 + updatedAt: prismaRole.updatedAt, 22 + }); 23 + } 24 + 25 + mapToDomain(prismaRoles: PrismaOrganizationRole[]): OrganizationRole[] { 26 + return prismaRoles.map((r) => this.toDomain(r)); 27 + } 28 + }
+7 -1
apps/server/src/modules/organization/organization.entity.ts
··· 31 31 this.updatedAt = data.updatedAt; 32 32 } 33 33 34 - static fromDomain(domainOrg: { id: string; name: string; description?: string | null; createdAt: Date; updatedAt: Date }): Organization { 34 + static fromDomain(domainOrg: { 35 + id: string; 36 + name: string; 37 + description?: string | null; 38 + createdAt: Date; 39 + updatedAt: Date; 40 + }): Organization { 35 41 return new Organization({ 36 42 id: domainOrg.id, 37 43 name: domainOrg.name,
+27
apps/server/src/modules/organization/organization.mapper.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import type { Organization as PrismaOrganization } from "@prisma/client"; 3 + import type { BaseMapper } from "../base/mapper.interface"; 4 + import { Organization } from "./organization.entity"; 5 + 6 + @Injectable() 7 + export class OrganizationMapper 8 + implements BaseMapper<PrismaOrganization, Organization> 9 + { 10 + toDomain(prismaOrganization: null): null; 11 + toDomain(prismaOrganization: PrismaOrganization): Organization; 12 + toDomain(prismaOrganization: PrismaOrganization | null): Organization | null; 13 + toDomain(prismaOrganization: PrismaOrganization | null): Organization | null { 14 + if (prismaOrganization === null) return null; 15 + return new Organization({ 16 + id: prismaOrganization.id, 17 + name: prismaOrganization.name, 18 + description: prismaOrganization.description, 19 + createdAt: prismaOrganization.createdAt, 20 + updatedAt: prismaOrganization.updatedAt, 21 + }); 22 + } 23 + 24 + mapToDomain(prismaOrganizations: PrismaOrganization[]): Organization[] { 25 + return prismaOrganizations.map((o) => this.toDomain(o)); 26 + } 27 + }
+17 -3
apps/server/src/modules/organization/organization.module.ts
··· 1 1 import { Module } from "@nestjs/common"; 2 2 import { DatabaseModule } from "../database/database.module"; 3 + import { OrganizationMapper } from "./organization.mapper"; 3 4 import { OrganizationResolver } from "./organization.resolver"; 5 + import { OrganizationService } from "./organization.service"; 6 + import { OrganizationRoleMapper } from "./organization-role.mapper"; 4 7 import { OrganizationRoleService } from "./organization-role.service"; 5 - import { OrganizationService } from "./organization.service"; 6 8 import { UserFieldResolver } from "./user-field.resolver"; 7 9 8 10 @Module({ 9 11 imports: [DatabaseModule], 10 - providers: [OrganizationService, OrganizationRoleService, OrganizationResolver, UserFieldResolver], 11 - exports: [OrganizationService, OrganizationRoleService], 12 + providers: [ 13 + OrganizationService, 14 + OrganizationRoleService, 15 + OrganizationResolver, 16 + UserFieldResolver, 17 + OrganizationMapper, 18 + OrganizationRoleMapper, 19 + ], 20 + exports: [ 21 + OrganizationService, 22 + OrganizationRoleService, 23 + OrganizationMapper, 24 + OrganizationRoleMapper, 25 + ], 12 26 }) 13 27 export class OrganizationModule {}
+31 -14
apps/server/src/modules/organization/organization.resolver.ts
··· 1 1 import { UseGuards } from "@nestjs/common"; 2 - import { Args, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; 2 + import { 3 + Args, 4 + Mutation, 5 + Parent, 6 + Query, 7 + ResolveField, 8 + Resolver, 9 + } from "@nestjs/graphql"; 3 10 import { JwtAuthGuard } from "../auth/jwt-auth.guard"; 4 - import { User } from "../auth/user.type"; 5 11 import { Organization } from "./organization.entity"; 12 + import { OrganizationService } from "./organization.service"; 6 13 import { OrganizationRole } from "./organization-role.entity"; 7 - import { OrganizationService } from "./organization.service"; 8 14 import { UserOrganization } from "./user-organization.type"; 9 15 10 16 @Resolver(() => Organization) ··· 18 24 } 19 25 20 26 @ResolveField(() => [UserOrganization]) 21 - async users(@Parent() organization: Organization): Promise<UserOrganization[]> { 22 - const userOrgs = await this.organizationService.findUsersByOrganizationId(organization.id); 23 - return userOrgs.map((uo) => UserOrganization.fromDomain({ 24 - id: uo.id, 25 - joinedAt: uo.joinedAt, 26 - user: uo.user, 27 - role: OrganizationRole.fromDomain(uo.role), 28 - })); 27 + async users( 28 + @Parent() organization: Organization, 29 + ): Promise<UserOrganization[]> { 30 + const userOrgs = await this.organizationService.findUsersByOrganizationId( 31 + organization.id, 32 + ); 33 + return userOrgs.map((uo) => 34 + UserOrganization.fromDomain({ 35 + id: uo.id, 36 + joinedAt: uo.joinedAt, 37 + user: uo.user, 38 + role: OrganizationRole.fromDomain(uo.role), 39 + }), 40 + ); 29 41 } 30 42 31 43 @Mutation(() => Organization) ··· 66 78 @Args("organizationId") organizationId: string, 67 79 @Args("userId") userId: string, 68 80 ): Promise<boolean> { 69 - return this.organizationService.addUserToOrganization(organizationId, userId); 81 + return this.organizationService.addUserToOrganization( 82 + organizationId, 83 + userId, 84 + ); 70 85 } 71 86 72 87 @Mutation(() => Boolean) ··· 74 89 @Args("organizationId") organizationId: string, 75 90 @Args("userId") userId: string, 76 91 ): Promise<boolean> { 77 - return this.organizationService.removeUserFromOrganization(organizationId, userId); 92 + return this.organizationService.removeUserFromOrganization( 93 + organizationId, 94 + userId, 95 + ); 78 96 } 79 - 80 97 }
+35 -37
apps/server/src/modules/organization/organization.service.ts
··· 1 1 import { Injectable, Logger } from "@nestjs/common"; 2 2 import { PrismaService } from "../database/prisma.service"; 3 - import type { CreateOrganizationDto, UpdateOrganizationDto } from "./organization.dto"; 3 + import type { 4 + CreateOrganizationDto, 5 + UpdateOrganizationDto, 6 + } from "./organization.dto"; 4 7 import { Organization } from "./organization.entity"; 5 - import { OrganizationRole } from "./organization-role.entity"; 8 + import { OrganizationMapper } from "./organization.mapper"; 6 9 7 10 @Injectable() 8 11 export class OrganizationService { 9 12 private readonly logger = new Logger(OrganizationService.name); 10 13 11 - constructor(private readonly prisma: PrismaService) {} 14 + constructor( 15 + private readonly prisma: PrismaService, 16 + private readonly organizationMapper: OrganizationMapper, 17 + ) {} 12 18 13 19 async findById(id: string): Promise<Organization | null> { 14 20 this.logger.log(`Finding organization by id: ${id}`); ··· 17 23 where: { id }, 18 24 }); 19 25 20 - return organization ? new Organization({ 21 - id: organization.id, 22 - name: organization.name, 23 - description: organization.description, 24 - createdAt: organization.createdAt, 25 - updatedAt: organization.updatedAt, 26 - }) : null; 26 + return this.organizationMapper.toDomain(organization); 27 27 } 28 28 29 29 async findForUser(userId: string): Promise<Organization[]> { ··· 37 37 orderBy: { createdAt: "asc" }, 38 38 }); 39 39 40 - return userOrganizations.map((uo) => Organization.fromDomain({ 41 - id: uo.organization.id, 42 - name: uo.organization.name, 43 - description: uo.organization.description, 44 - createdAt: uo.organization.createdAt, 45 - updatedAt: uo.organization.updatedAt, 46 - })); 40 + return this.organizationMapper.mapToDomain( 41 + userOrganizations.map((uo) => uo.organization), 42 + ); 47 43 } 48 44 49 45 async findUsersByOrganizationId(organizationId: string) { ··· 73 69 })); 74 70 } 75 71 76 - async create(createOrganizationDto: CreateOrganizationDto): Promise<Organization> { 72 + async create( 73 + createOrganizationDto: CreateOrganizationDto, 74 + ): Promise<Organization> { 77 75 this.logger.log(`Creating organization: ${createOrganizationDto.name}`); 78 76 79 77 const organization = await this.prisma.organization.create({ ··· 83 81 }, 84 82 }); 85 83 86 - return new Organization({ 87 - id: organization.id, 88 - name: organization.name, 89 - description: organization.description, 90 - createdAt: organization.createdAt, 91 - updatedAt: organization.updatedAt, 92 - }); 84 + return this.organizationMapper.toDomain(organization); 93 85 } 94 86 95 - async update(id: string, updateOrganizationDto: UpdateOrganizationDto): Promise<Organization> { 87 + async update( 88 + id: string, 89 + updateOrganizationDto: UpdateOrganizationDto, 90 + ): Promise<Organization> { 96 91 this.logger.log(`Updating organization: ${id}`); 97 92 98 93 const updateData: Partial<{ ··· 111 106 data: updateData, 112 107 }); 113 108 114 - return new Organization({ 115 - id: organization.id, 116 - name: organization.name, 117 - description: organization.description, 118 - createdAt: organization.createdAt, 119 - updatedAt: organization.updatedAt, 120 - }); 109 + return this.organizationMapper.toDomain(organization); 121 110 } 122 111 123 112 async delete(id: string): Promise<boolean> { ··· 130 119 return true; 131 120 } 132 121 133 - async addUserToOrganization(organizationId: string, userId: string, roleId?: string): Promise<boolean> { 122 + async addUserToOrganization( 123 + organizationId: string, 124 + userId: string, 125 + roleId?: string, 126 + ): Promise<boolean> { 134 127 this.logger.log(`Adding user ${userId} to organization ${organizationId}`); 135 128 136 129 // Default to member role if no role specified 137 - const defaultRoleId = roleId || 'member_role_id'; 130 + const defaultRoleId = roleId || "member_role_id"; 138 131 139 132 await this.prisma.userOrganization.create({ 140 133 data: { ··· 147 140 return true; 148 141 } 149 142 150 - async removeUserFromOrganization(organizationId: string, userId: string): Promise<boolean> { 151 - this.logger.log(`Removing user ${userId} from organization ${organizationId}`); 143 + async removeUserFromOrganization( 144 + organizationId: string, 145 + userId: string, 146 + ): Promise<boolean> { 147 + this.logger.log( 148 + `Removing user ${userId} from organization ${organizationId}`, 149 + ); 152 150 153 151 await this.prisma.userOrganization.deleteMany({ 154 152 where: {
+1 -1
apps/server/src/modules/organization/user-field.resolver.ts
··· 1 - import { ResolveField, Parent, Resolver } from "@nestjs/graphql"; 1 + import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; 2 2 import { User } from "../auth/user.type"; 3 3 import { Organization } from "./organization.entity"; 4 4 import { OrganizationService } from "./organization.service";
+4 -4
apps/server/src/modules/organization/user-organization.type.ts
··· 28 28 this.role = data.role; 29 29 } 30 30 31 - static fromDomain(domainUserOrg: { 32 - id: string; 33 - joinedAt: Date; 34 - user: User; 31 + static fromDomain(domainUserOrg: { 32 + id: string; 33 + joinedAt: Date; 34 + user: User; 35 35 role: OrganizationRole; 36 36 }): UserOrganization { 37 37 return new UserOrganization({
+60
apps/server/src/modules/vacancies/vacancy.entity.ts
··· 1 + import { BaseEntity } from "../base/base.entity"; 2 + 3 + export class Vacancy extends BaseEntity { 4 + userId: string; 5 + title: string; 6 + company: string; 7 + description?: string; 8 + requirements?: string; 9 + location?: string; 10 + salary?: string; 11 + jobType?: string; 12 + applicationUrl?: string; 13 + deadline?: Date; 14 + isActive: boolean; 15 + 16 + constructor( 17 + id: string, 18 + userId: string, 19 + title: string, 20 + company: string, 21 + createdAt: Date, 22 + updatedAt: Date, 23 + description?: string, 24 + requirements?: string, 25 + location?: string, 26 + salary?: string, 27 + jobType?: string, 28 + applicationUrl?: string, 29 + deadline?: Date, 30 + isActive: boolean = true, 31 + ) { 32 + super(id, createdAt, updatedAt); 33 + this.userId = userId; 34 + this.title = title; 35 + this.company = company; 36 + this.isActive = isActive; 37 + 38 + if (description !== undefined) { 39 + this.description = description; 40 + } 41 + if (requirements !== undefined) { 42 + this.requirements = requirements; 43 + } 44 + if (location !== undefined) { 45 + this.location = location; 46 + } 47 + if (salary !== undefined) { 48 + this.salary = salary; 49 + } 50 + if (jobType !== undefined) { 51 + this.jobType = jobType; 52 + } 53 + if (applicationUrl !== undefined) { 54 + this.applicationUrl = applicationUrl; 55 + } 56 + if (deadline !== undefined) { 57 + this.deadline = deadline; 58 + } 59 + } 60 + }
+58
apps/server/src/modules/vacancies/vacancy.resolver.ts
··· 1 + import { UseGuards } from "@nestjs/common"; 2 + import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; 3 + import { CurrentUser } from "../auth/current-user.decorator"; 4 + import { JwtAuthGuard } from "../auth/jwt-auth.guard"; 5 + import { User } from "../auth/user.type"; 6 + import { VacancyService } from "./vacancy.service"; 7 + import { Vacancy } from "./vacancy.type"; 8 + 9 + @Resolver(() => Vacancy) 10 + @UseGuards(JwtAuthGuard) 11 + export class VacancyResolver { 12 + constructor(private readonly vacancyService: VacancyService) {} 13 + 14 + @Query(() => [Vacancy]) 15 + async myVacancies(@CurrentUser() user: User): Promise<Vacancy[]> { 16 + const domainVacancies = await this.vacancyService.findForUser(user.id); 17 + return domainVacancies.map((vacancy) => Vacancy.fromDomain(vacancy)); 18 + } 19 + 20 + @Mutation(() => Vacancy) 21 + async createVacancy( 22 + @CurrentUser() user: User, 23 + @Args("title") title: string, 24 + @Args("company") company: string, 25 + @Args("description", { nullable: true }) description?: string, 26 + @Args("requirements", { nullable: true }) requirements?: string, 27 + @Args("location", { nullable: true }) location?: string, 28 + @Args("salary", { nullable: true }) salary?: string, 29 + @Args("jobType", { nullable: true }) jobType?: string, 30 + @Args("applicationUrl", { nullable: true }) applicationUrl?: string, 31 + @Args("deadline", { nullable: true }) deadline?: Date, 32 + @Args("isActive", { nullable: true }) isActive?: boolean, 33 + ): Promise<Vacancy> { 34 + const createData = { 35 + title, 36 + company, 37 + ...(description !== undefined && { description }), 38 + ...(requirements !== undefined && { requirements }), 39 + ...(location !== undefined && { location }), 40 + ...(salary !== undefined && { salary }), 41 + ...(jobType !== undefined && { jobType }), 42 + ...(applicationUrl !== undefined && { applicationUrl }), 43 + ...(deadline !== undefined && { deadline }), 44 + ...(isActive !== undefined && { isActive }), 45 + }; 46 + 47 + const domainVacancy = await this.vacancyService.create(user.id, createData); 48 + return Vacancy.fromDomain(domainVacancy); 49 + } 50 + 51 + @Mutation(() => Boolean) 52 + async deleteVacancy( 53 + @CurrentUser() user: User, 54 + @Args("id") id: string, 55 + ): Promise<boolean> { 56 + return this.vacancyService.delete(id, user.id); 57 + } 58 + }
+224
apps/server/test/coverage-unit/lcov-report/base.css
··· 1 + body, html { 2 + margin:0; padding: 0; 3 + height: 100%; 4 + } 5 + body { 6 + font-family: Helvetica Neue, Helvetica, Arial; 7 + font-size: 14px; 8 + color:#333; 9 + } 10 + .small { font-size: 12px; } 11 + *, *:after, *:before { 12 + -webkit-box-sizing:border-box; 13 + -moz-box-sizing:border-box; 14 + box-sizing:border-box; 15 + } 16 + h1 { font-size: 20px; margin: 0;} 17 + h2 { font-size: 14px; } 18 + pre { 19 + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; 20 + margin: 0; 21 + padding: 0; 22 + -moz-tab-size: 2; 23 + -o-tab-size: 2; 24 + tab-size: 2; 25 + } 26 + a { color:#0074D9; text-decoration:none; } 27 + a:hover { text-decoration:underline; } 28 + .strong { font-weight: bold; } 29 + .space-top1 { padding: 10px 0 0 0; } 30 + .pad2y { padding: 20px 0; } 31 + .pad1y { padding: 10px 0; } 32 + .pad2x { padding: 0 20px; } 33 + .pad2 { padding: 20px; } 34 + .pad1 { padding: 10px; } 35 + .space-left2 { padding-left:55px; } 36 + .space-right2 { padding-right:20px; } 37 + .center { text-align:center; } 38 + .clearfix { display:block; } 39 + .clearfix:after { 40 + content:''; 41 + display:block; 42 + height:0; 43 + clear:both; 44 + visibility:hidden; 45 + } 46 + .fl { float: left; } 47 + @media only screen and (max-width:640px) { 48 + .col3 { width:100%; max-width:100%; } 49 + .hide-mobile { display:none!important; } 50 + } 51 + 52 + .quiet { 53 + color: #7f7f7f; 54 + color: rgba(0,0,0,0.5); 55 + } 56 + .quiet a { opacity: 0.7; } 57 + 58 + .fraction { 59 + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; 60 + font-size: 10px; 61 + color: #555; 62 + background: #E8E8E8; 63 + padding: 4px 5px; 64 + border-radius: 3px; 65 + vertical-align: middle; 66 + } 67 + 68 + div.path a:link, div.path a:visited { color: #333; } 69 + table.coverage { 70 + border-collapse: collapse; 71 + margin: 10px 0 0 0; 72 + padding: 0; 73 + } 74 + 75 + table.coverage td { 76 + margin: 0; 77 + padding: 0; 78 + vertical-align: top; 79 + } 80 + table.coverage td.line-count { 81 + text-align: right; 82 + padding: 0 5px 0 20px; 83 + } 84 + table.coverage td.line-coverage { 85 + text-align: right; 86 + padding-right: 10px; 87 + min-width:20px; 88 + } 89 + 90 + table.coverage td span.cline-any { 91 + display: inline-block; 92 + padding: 0 5px; 93 + width: 100%; 94 + } 95 + .missing-if-branch { 96 + display: inline-block; 97 + margin-right: 5px; 98 + border-radius: 3px; 99 + position: relative; 100 + padding: 0 4px; 101 + background: #333; 102 + color: yellow; 103 + } 104 + 105 + .skip-if-branch { 106 + display: none; 107 + margin-right: 10px; 108 + position: relative; 109 + padding: 0 4px; 110 + background: #ccc; 111 + color: white; 112 + } 113 + .missing-if-branch .typ, .skip-if-branch .typ { 114 + color: inherit !important; 115 + } 116 + .coverage-summary { 117 + border-collapse: collapse; 118 + width: 100%; 119 + } 120 + .coverage-summary tr { border-bottom: 1px solid #bbb; } 121 + .keyline-all { border: 1px solid #ddd; } 122 + .coverage-summary td, .coverage-summary th { padding: 10px; } 123 + .coverage-summary tbody { border: 1px solid #bbb; } 124 + .coverage-summary td { border-right: 1px solid #bbb; } 125 + .coverage-summary td:last-child { border-right: none; } 126 + .coverage-summary th { 127 + text-align: left; 128 + font-weight: normal; 129 + white-space: nowrap; 130 + } 131 + .coverage-summary th.file { border-right: none !important; } 132 + .coverage-summary th.pct { } 133 + .coverage-summary th.pic, 134 + .coverage-summary th.abs, 135 + .coverage-summary td.pct, 136 + .coverage-summary td.abs { text-align: right; } 137 + .coverage-summary td.file { white-space: nowrap; } 138 + .coverage-summary td.pic { min-width: 120px !important; } 139 + .coverage-summary tfoot td { } 140 + 141 + .coverage-summary .sorter { 142 + height: 10px; 143 + width: 7px; 144 + display: inline-block; 145 + margin-left: 0.5em; 146 + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; 147 + } 148 + .coverage-summary .sorted .sorter { 149 + background-position: 0 -20px; 150 + } 151 + .coverage-summary .sorted-desc .sorter { 152 + background-position: 0 -10px; 153 + } 154 + .status-line { height: 10px; } 155 + /* yellow */ 156 + .cbranch-no { background: yellow !important; color: #111; } 157 + /* dark red */ 158 + .red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } 159 + .low .chart { border:1px solid #C21F39 } 160 + .highlighted, 161 + .highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ 162 + background: #C21F39 !important; 163 + } 164 + /* medium red */ 165 + .cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } 166 + /* light red */ 167 + .low, .cline-no { background:#FCE1E5 } 168 + /* light green */ 169 + .high, .cline-yes { background:rgb(230,245,208) } 170 + /* medium green */ 171 + .cstat-yes { background:rgb(161,215,106) } 172 + /* dark green */ 173 + .status-line.high, .high .cover-fill { background:rgb(77,146,33) } 174 + .high .chart { border:1px solid rgb(77,146,33) } 175 + /* dark yellow (gold) */ 176 + .status-line.medium, .medium .cover-fill { background: #f9cd0b; } 177 + .medium .chart { border:1px solid #f9cd0b; } 178 + /* light yellow */ 179 + .medium { background: #fff4c2; } 180 + 181 + .cstat-skip { background: #ddd; color: #111; } 182 + .fstat-skip { background: #ddd; color: #111 !important; } 183 + .cbranch-skip { background: #ddd !important; color: #111; } 184 + 185 + span.cline-neutral { background: #eaeaea; } 186 + 187 + .coverage-summary td.empty { 188 + opacity: .5; 189 + padding-top: 4px; 190 + padding-bottom: 4px; 191 + line-height: 1; 192 + color: #888; 193 + } 194 + 195 + .cover-fill, .cover-empty { 196 + display:inline-block; 197 + height: 12px; 198 + } 199 + .chart { 200 + line-height: 0; 201 + } 202 + .cover-empty { 203 + background: white; 204 + } 205 + .cover-full { 206 + border-right: none !important; 207 + } 208 + pre.prettyprint { 209 + border: none !important; 210 + padding: 0 !important; 211 + margin: 0 !important; 212 + } 213 + .com { color: #999 !important; } 214 + .ignore-none { color: #999; font-weight: normal; } 215 + 216 + .wrapper { 217 + min-height: 100%; 218 + height: auto !important; 219 + height: 100%; 220 + margin: 0 auto -48px; 221 + } 222 + .footer, .push { 223 + height: 48px; 224 + }
+87
apps/server/test/coverage-unit/lcov-report/block-navigation.js
··· 1 + /* eslint-disable */ 2 + var jumpToCode = (function init() { 3 + // Classes of code we would like to highlight in the file view 4 + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; 5 + 6 + // Elements to highlight in the file listing view 7 + var fileListingElements = ['td.pct.low']; 8 + 9 + // We don't want to select elements that are direct descendants of another match 10 + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` 11 + 12 + // Selector that finds elements on the page to which we can jump 13 + var selector = 14 + fileListingElements.join(', ') + 15 + ', ' + 16 + notSelector + 17 + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` 18 + 19 + // The NodeList of matching elements 20 + var missingCoverageElements = document.querySelectorAll(selector); 21 + 22 + var currentIndex; 23 + 24 + function toggleClass(index) { 25 + missingCoverageElements 26 + .item(currentIndex) 27 + .classList.remove('highlighted'); 28 + missingCoverageElements.item(index).classList.add('highlighted'); 29 + } 30 + 31 + function makeCurrent(index) { 32 + toggleClass(index); 33 + currentIndex = index; 34 + missingCoverageElements.item(index).scrollIntoView({ 35 + behavior: 'smooth', 36 + block: 'center', 37 + inline: 'center' 38 + }); 39 + } 40 + 41 + function goToPrevious() { 42 + var nextIndex = 0; 43 + if (typeof currentIndex !== 'number' || currentIndex === 0) { 44 + nextIndex = missingCoverageElements.length - 1; 45 + } else if (missingCoverageElements.length > 1) { 46 + nextIndex = currentIndex - 1; 47 + } 48 + 49 + makeCurrent(nextIndex); 50 + } 51 + 52 + function goToNext() { 53 + var nextIndex = 0; 54 + 55 + if ( 56 + typeof currentIndex === 'number' && 57 + currentIndex < missingCoverageElements.length - 1 58 + ) { 59 + nextIndex = currentIndex + 1; 60 + } 61 + 62 + makeCurrent(nextIndex); 63 + } 64 + 65 + return function jump(event) { 66 + if ( 67 + document.getElementById('fileSearch') === document.activeElement && 68 + document.activeElement != null 69 + ) { 70 + // if we're currently focused on the search input, we don't want to navigate 71 + return; 72 + } 73 + 74 + switch (event.which) { 75 + case 78: // n 76 + case 74: // j 77 + goToNext(); 78 + break; 79 + case 66: // b 80 + case 75: // k 81 + case 80: // p 82 + goToPrevious(); 83 + break; 84 + } 85 + }; 86 + })(); 87 + window.addEventListener('keydown', jumpToCode);
apps/server/test/coverage-unit/lcov-report/favicon.png

This is a binary file and will not be displayed.

+101
apps/server/test/coverage-unit/lcov-report/index.html
··· 1 + 2 + <!doctype html> 3 + <html lang="en"> 4 + 5 + <head> 6 + <title>Code coverage report for All files</title> 7 + <meta charset="utf-8" /> 8 + <link rel="stylesheet" href="prettify.css" /> 9 + <link rel="stylesheet" href="base.css" /> 10 + <link rel="shortcut icon" type="image/x-icon" href="favicon.png" /> 11 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 12 + <style type='text/css'> 13 + .coverage-summary .sorter { 14 + background-image: url(sort-arrow-sprite.png); 15 + } 16 + </style> 17 + </head> 18 + 19 + <body> 20 + <div class='wrapper'> 21 + <div class='pad1'> 22 + <h1>All files</h1> 23 + <div class='clearfix'> 24 + 25 + <div class='fl pad1y space-right2'> 26 + <span class="strong">Unknown% </span> 27 + <span class="quiet">Statements</span> 28 + <span class='fraction'>0/0</span> 29 + </div> 30 + 31 + 32 + <div class='fl pad1y space-right2'> 33 + <span class="strong">Unknown% </span> 34 + <span class="quiet">Branches</span> 35 + <span class='fraction'>0/0</span> 36 + </div> 37 + 38 + 39 + <div class='fl pad1y space-right2'> 40 + <span class="strong">Unknown% </span> 41 + <span class="quiet">Functions</span> 42 + <span class='fraction'>0/0</span> 43 + </div> 44 + 45 + 46 + <div class='fl pad1y space-right2'> 47 + <span class="strong">Unknown% </span> 48 + <span class="quiet">Lines</span> 49 + <span class='fraction'>0/0</span> 50 + </div> 51 + 52 + 53 + </div> 54 + <p class="quiet"> 55 + Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block. 56 + </p> 57 + <template id="filterTemplate"> 58 + <div class="quiet"> 59 + Filter: 60 + <input type="search" id="fileSearch"> 61 + </div> 62 + </template> 63 + </div> 64 + <div class='status-line medium'></div> 65 + <div class="pad1"> 66 + <table class="coverage-summary"> 67 + <thead> 68 + <tr> 69 + <th data-col="file" data-fmt="html" data-html="true" class="file">File</th> 70 + <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th> 71 + <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th> 72 + <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th> 73 + <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th> 74 + <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th> 75 + <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th> 76 + <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th> 77 + <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th> 78 + <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th> 79 + </tr> 80 + </thead> 81 + <tbody></tbody> 82 + </table> 83 + </div> 84 + <div class='push'></div><!-- for sticky footer --> 85 + </div><!-- /wrapper --> 86 + <div class='footer quiet pad2 space-top1 center small'> 87 + Code coverage generated by 88 + <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a> 89 + at 2025-10-20T12:27:27.089Z 90 + </div> 91 + <script src="prettify.js"></script> 92 + <script> 93 + window.onload = function () { 94 + prettyPrint(); 95 + }; 96 + </script> 97 + <script src="sorter.js"></script> 98 + <script src="block-navigation.js"></script> 99 + </body> 100 + </html> 101 +
+1
apps/server/test/coverage-unit/lcov-report/prettify.css
··· 1 + .pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
+2
apps/server/test/coverage-unit/lcov-report/prettify.js
··· 1 + /* eslint-disable */ 2 + window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V<U;++V){var ae=Z[V];if(ae.ignoreCase){ac=true}else{if(/[a-z]/i.test(ae.source.replace(/\\u[0-9a-f]{4}|\\x[0-9a-f]{2}|\\[^ux]/gi,""))){S=true;ac=false;break}}}var Y={b:8,t:9,n:10,v:11,f:12,r:13};function ab(ah){var ag=ah.charCodeAt(0);if(ag!==92){return ag}var af=ah.charAt(1);ag=Y[af];if(ag){return ag}else{if("0"<=af&&af<="7"){return parseInt(ah.substring(1),8)}else{if(af==="u"||af==="x"){return parseInt(ah.substring(2),16)}else{return ah.charCodeAt(1)}}}}function T(af){if(af<32){return(af<16?"\\x0":"\\x")+af.toString(16)}var ag=String.fromCharCode(af);if(ag==="\\"||ag==="-"||ag==="["||ag==="]"){ag="\\"+ag}return ag}function X(am){var aq=am.substring(1,am.length-1).match(new RegExp("\\\\u[0-9A-Fa-f]{4}|\\\\x[0-9A-Fa-f]{2}|\\\\[0-3][0-7]{0,2}|\\\\[0-7]{1,2}|\\\\[\\s\\S]|-|[^-\\\\]","g"));var ak=[];var af=[];var ao=aq[0]==="^";for(var ar=ao?1:0,aj=aq.length;ar<aj;++ar){var ah=aq[ar];if(/\\[bdsw]/i.test(ah)){ak.push(ah)}else{var ag=ab(ah);var al;if(ar+2<aj&&"-"===aq[ar+1]){al=ab(aq[ar+2]);ar+=2}else{al=ag}af.push([ag,al]);if(!(al<65||ag>122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;ar<af.length;++ar){var at=af[ar];if(at[0]<=ap[1]+1){ap[1]=Math.max(ap[1],at[1])}else{ai.push(ap=at)}}var an=["["];if(ao){an.push("^")}an.push.apply(an,ak);for(var ar=0;ar<ai.length;++ar){var at=ai[ar];an.push(T(at[0]));if(at[1]>at[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak<ah;++ak){var ag=aj[ak];if(ag==="("){++am}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){an[af]=-1}}}}for(var ak=1;ak<an.length;++ak){if(-1===an[ak]){an[ak]=++ad}}for(var ak=0,am=0;ak<ah;++ak){var ag=aj[ak];if(ag==="("){++am;if(an[am]===undefined){aj[ak]="(?:"}}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){aj[ak]="\\"+an[am]}}}}for(var ak=0,am=0;ak<ah;++ak){if("^"===aj[ak]&&"^"!==aj[ak+1]){aj[ak]=""}}if(al.ignoreCase&&S){for(var ak=0;ak<ah;++ak){var ag=aj[ak];var ai=ag.charAt(0);if(ag.length>=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V<U;++V){var ae=Z[V];if(ae.global||ae.multiline){throw new Error(""+ae)}aa.push("(?:"+W(ae)+")")}return new RegExp(aa.join("|"),ac?"gi":"g")}function a(V){var U=/(?:^|\s)nocode(?:\s|$)/;var X=[];var T=0;var Z=[];var W=0;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=document.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Y=S&&"pre"===S.substring(0,3);function aa(ab){switch(ab.nodeType){case 1:if(U.test(ab.className)){return}for(var ae=ab.firstChild;ae;ae=ae.nextSibling){aa(ae)}var ad=ab.nodeName;if("BR"===ad||"LI"===ad){X[W]="\n";Z[W<<1]=T++;Z[(W++<<1)|1]=ab}break;case 3:case 4:var ac=ab.nodeValue;if(ac.length){if(!Y){ac=ac.replace(/[ \t\r\n]+/g," ")}else{ac=ac.replace(/\r\n?/g,"\n")}X[W]=ac;Z[W<<1]=T;T+=ac.length;Z[(W++<<1)|1]=ab}break}}aa(V);return{sourceCode:X.join("").replace(/\n$/,""),spans:Z}}function B(S,U,W,T){if(!U){return}var V={sourceCode:U,basePos:S};W(V);T.push.apply(T,V.decorations)}var v=/\S/;function o(S){var V=undefined;for(var U=S.firstChild;U;U=U.nextSibling){var T=U.nodeType;V=(T===1)?(V?S:U):(T===3)?(v.test(U.nodeValue)?S:V):V}return V===S?undefined:V}function g(U,T){var S={};var V;(function(){var ad=U.concat(T);var ah=[];var ag={};for(var ab=0,Z=ad.length;ab<Z;++ab){var Y=ad[ab];var ac=Y[3];if(ac){for(var ae=ac.length;--ae>=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae<aq;++ae){var ag=an[ae];var ap=aj[ag];var ai=void 0;var am;if(typeof ap==="string"){am=false}else{var aa=S[ag.charAt(0)];if(aa){ai=ag.match(aa[1]);ap=aa[0]}else{for(var ao=0;ao<X;++ao){aa=T[ao];ai=ag.match(aa[1]);if(ai){ap=aa[0];break}}if(!ai){ap=F}}am=ap.length>=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y<W.length;++Y){ae(W[Y])}if(ag===(ag|0)){W[0].setAttribute("value",ag)}var aa=ac.createElement("OL");aa.className="linenums";var X=Math.max(0,((ag-1))|0)||0;for(var Y=0,T=W.length;Y<T;++Y){af=W[Y];af.className="L"+((Y+X)%10);if(!af.firstChild){af.appendChild(ac.createTextNode("\xA0"))}aa.appendChild(af)}V.appendChild(aa)}function D(ac){var aj=/\bMSIE\b/.test(navigator.userAgent);var am=/\n/g;var al=ac.sourceCode;var an=al.length;var V=0;var aa=ac.spans;var T=aa.length;var ah=0;var X=ac.decorations;var Y=X.length;var Z=0;X[Y]=an;var ar,aq;for(aq=ar=0;aq<Y;){if(X[aq]!==X[aq+2]){X[ar++]=X[aq++];X[ar++]=X[aq++]}else{aq+=2}}Y=ar;for(aq=ar=0;aq<Y;){var at=X[aq];var ab=X[aq+1];var W=aq+2;while(W+2<=Y&&X[W+1]===ab){W+=2}X[ar++]=at;X[ar++]=ab;aq=W}Y=X.length=ar;var ae=null;while(ah<T){var af=aa[ah];var S=aa[ah+2]||an;var ag=X[Z];var ap=X[Z+2]||an;var W=Math.min(S,ap);var ak=aa[ah+1];var U;if(ak.nodeType!==1&&(U=al.substring(V,W))){if(aj){U=U.replace(am,"\r")}ak.nodeValue=U;var ai=ak.ownerDocument;var ao=ai.createElement("SPAN");ao.className=X[Z+1];var ad=ak.parentNode;ad.replaceChild(ao,ak);ao.appendChild(ak);if(V<S){aa[ah+1]=ak=ai.createTextNode(al.substring(W,S));ad.insertBefore(ak,ao.nextSibling)}}V=W;if(V>=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*</.test(S)?"default-markup":"default-code"}return t[T]}c(K,["default-code"]);c(g([],[[F,/^[^<?]+/],[E,/^<!\w[^>]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^<xmp\b[^>]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^<script\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^<style\b[^>]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa<ac.length;++aa){for(var Z=0,V=ac[aa].length;Z<V;++Z){T.push(ac[aa][Z])}}ac=null;var W=Date;if(!W.now){W={now:function(){return +(new Date)}}}var X=0;var S;var ab=/\blang(?:uage)?-([\w.]+)(?!\S)/;var ae=/\bprettyprint\b/;function U(){var ag=(window.PR_SHOULD_USE_CONTINUATION?W.now()+250:Infinity);for(;X<T.length&&W.now()<ag;X++){var aj=T[X];var ai=aj.className;if(ai.indexOf("prettyprint")>=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X<T.length){setTimeout(U,250)}else{if(ad){ad()}}}U()}window.prettyPrintOne=y;window.prettyPrint=b;window.PR={createSimpleLexer:g,registerLangHandler:c,sourceDecorator:i,PR_ATTRIB_NAME:P,PR_ATTRIB_VALUE:n,PR_COMMENT:j,PR_DECLARATION:E,PR_KEYWORD:z,PR_LITERAL:G,PR_NOCODE:N,PR_PLAIN:F,PR_PUNCTUATION:L,PR_SOURCE:J,PR_STRING:C,PR_TAG:m,PR_TYPE:O}})();PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_DECLARATION,/^<!\w[^>]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^<xmp\b[^>]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^<script\b[^>]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^<script\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^<style\b[^>]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:<!--|-->)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]);
apps/server/test/coverage-unit/lcov-report/sort-arrow-sprite.png

This is a binary file and will not be displayed.

+210
apps/server/test/coverage-unit/lcov-report/sorter.js
··· 1 + /* eslint-disable */ 2 + var addSorting = (function() { 3 + 'use strict'; 4 + var cols, 5 + currentSort = { 6 + index: 0, 7 + desc: false 8 + }; 9 + 10 + // returns the summary table element 11 + function getTable() { 12 + return document.querySelector('.coverage-summary'); 13 + } 14 + // returns the thead element of the summary table 15 + function getTableHeader() { 16 + return getTable().querySelector('thead tr'); 17 + } 18 + // returns the tbody element of the summary table 19 + function getTableBody() { 20 + return getTable().querySelector('tbody'); 21 + } 22 + // returns the th element for nth column 23 + function getNthColumn(n) { 24 + return getTableHeader().querySelectorAll('th')[n]; 25 + } 26 + 27 + function onFilterInput() { 28 + const searchValue = document.getElementById('fileSearch').value; 29 + const rows = document.getElementsByTagName('tbody')[0].children; 30 + 31 + // Try to create a RegExp from the searchValue. If it fails (invalid regex), 32 + // it will be treated as a plain text search 33 + let searchRegex; 34 + try { 35 + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive 36 + } catch (error) { 37 + searchRegex = null; 38 + } 39 + 40 + for (let i = 0; i < rows.length; i++) { 41 + const row = rows[i]; 42 + let isMatch = false; 43 + 44 + if (searchRegex) { 45 + // If a valid regex was created, use it for matching 46 + isMatch = searchRegex.test(row.textContent); 47 + } else { 48 + // Otherwise, fall back to the original plain text search 49 + isMatch = row.textContent 50 + .toLowerCase() 51 + .includes(searchValue.toLowerCase()); 52 + } 53 + 54 + row.style.display = isMatch ? '' : 'none'; 55 + } 56 + } 57 + 58 + // loads the search box 59 + function addSearchBox() { 60 + var template = document.getElementById('filterTemplate'); 61 + var templateClone = template.content.cloneNode(true); 62 + templateClone.getElementById('fileSearch').oninput = onFilterInput; 63 + template.parentElement.appendChild(templateClone); 64 + } 65 + 66 + // loads all columns 67 + function loadColumns() { 68 + var colNodes = getTableHeader().querySelectorAll('th'), 69 + colNode, 70 + cols = [], 71 + col, 72 + i; 73 + 74 + for (i = 0; i < colNodes.length; i += 1) { 75 + colNode = colNodes[i]; 76 + col = { 77 + key: colNode.getAttribute('data-col'), 78 + sortable: !colNode.getAttribute('data-nosort'), 79 + type: colNode.getAttribute('data-type') || 'string' 80 + }; 81 + cols.push(col); 82 + if (col.sortable) { 83 + col.defaultDescSort = col.type === 'number'; 84 + colNode.innerHTML = 85 + colNode.innerHTML + '<span class="sorter"></span>'; 86 + } 87 + } 88 + return cols; 89 + } 90 + // attaches a data attribute to every tr element with an object 91 + // of data values keyed by column name 92 + function loadRowData(tableRow) { 93 + var tableCols = tableRow.querySelectorAll('td'), 94 + colNode, 95 + col, 96 + data = {}, 97 + i, 98 + val; 99 + for (i = 0; i < tableCols.length; i += 1) { 100 + colNode = tableCols[i]; 101 + col = cols[i]; 102 + val = colNode.getAttribute('data-value'); 103 + if (col.type === 'number') { 104 + val = Number(val); 105 + } 106 + data[col.key] = val; 107 + } 108 + return data; 109 + } 110 + // loads all row data 111 + function loadData() { 112 + var rows = getTableBody().querySelectorAll('tr'), 113 + i; 114 + 115 + for (i = 0; i < rows.length; i += 1) { 116 + rows[i].data = loadRowData(rows[i]); 117 + } 118 + } 119 + // sorts the table using the data for the ith column 120 + function sortByIndex(index, desc) { 121 + var key = cols[index].key, 122 + sorter = function(a, b) { 123 + a = a.data[key]; 124 + b = b.data[key]; 125 + return a < b ? -1 : a > b ? 1 : 0; 126 + }, 127 + finalSorter = sorter, 128 + tableBody = document.querySelector('.coverage-summary tbody'), 129 + rowNodes = tableBody.querySelectorAll('tr'), 130 + rows = [], 131 + i; 132 + 133 + if (desc) { 134 + finalSorter = function(a, b) { 135 + return -1 * sorter(a, b); 136 + }; 137 + } 138 + 139 + for (i = 0; i < rowNodes.length; i += 1) { 140 + rows.push(rowNodes[i]); 141 + tableBody.removeChild(rowNodes[i]); 142 + } 143 + 144 + rows.sort(finalSorter); 145 + 146 + for (i = 0; i < rows.length; i += 1) { 147 + tableBody.appendChild(rows[i]); 148 + } 149 + } 150 + // removes sort indicators for current column being sorted 151 + function removeSortIndicators() { 152 + var col = getNthColumn(currentSort.index), 153 + cls = col.className; 154 + 155 + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); 156 + col.className = cls; 157 + } 158 + // adds sort indicators for current column being sorted 159 + function addSortIndicators() { 160 + getNthColumn(currentSort.index).className += currentSort.desc 161 + ? ' sorted-desc' 162 + : ' sorted'; 163 + } 164 + // adds event listeners for all sorter widgets 165 + function enableUI() { 166 + var i, 167 + el, 168 + ithSorter = function ithSorter(i) { 169 + var col = cols[i]; 170 + 171 + return function() { 172 + var desc = col.defaultDescSort; 173 + 174 + if (currentSort.index === i) { 175 + desc = !currentSort.desc; 176 + } 177 + sortByIndex(i, desc); 178 + removeSortIndicators(); 179 + currentSort.index = i; 180 + currentSort.desc = desc; 181 + addSortIndicators(); 182 + }; 183 + }; 184 + for (i = 0; i < cols.length; i += 1) { 185 + if (cols[i].sortable) { 186 + // add the click event handler on the th so users 187 + // dont have to click on those tiny arrows 188 + el = getNthColumn(i).querySelector('.sorter').parentElement; 189 + if (el.addEventListener) { 190 + el.addEventListener('click', ithSorter(i)); 191 + } else { 192 + el.attachEvent('onclick', ithSorter(i)); 193 + } 194 + } 195 + } 196 + } 197 + // adds sorting functionality to the UI 198 + return function() { 199 + if (!getTable()) { 200 + return; 201 + } 202 + cols = loadColumns(); 203 + loadData(); 204 + addSearchBox(); 205 + addSortIndicators(); 206 + enableUI(); 207 + }; 208 + })(); 209 + 210 + window.addEventListener('load', addSorting);
apps/server/test/coverage-unit/lcov.info

This is a binary file and will not be displayed.

-36
packages/utils/index.js
··· 1 - /** 2 - * Throws an error with the given message. 3 - * This is useful for creating never-returning functions that help with TypeScript's control flow analysis. 4 - * 5 - * @param message - The error message to throw 6 - * @throws {Error} Always throws an error 7 - * @returns {never} This function never returns 8 - * 9 - * @example 10 - * ```typescript 11 - * const user = getUser() ?? raise("User not found"); 12 - * // TypeScript knows user is not null/undefined after this line 13 - * ``` 14 - */ 15 - export const raise = (message) => { 16 - throw new Error(message); 17 - }; 18 - /** 19 - * Asserts that a condition is true, throwing an error if it's not. 20 - * 21 - * @param condition - The condition to assert 22 - * @param message - The error message to throw if condition is false 23 - * @throws {Error} Throws if condition is false 24 - * @returns {asserts condition} TypeScript assertion 25 - * 26 - * @example 27 - * ```typescript 28 - * assert(user !== null, "User must not be null"); 29 - * // TypeScript knows user is not null after this line 30 - * ``` 31 - */ 32 - export const assert = (condition, message) => { 33 - if (!condition) { 34 - throw new Error(message); 35 - } 36 - };