diff --git a/backend/src/graphql/resolvers/analytics.resolver.ts b/backend/src/graphql/resolvers/analytics.resolver.ts index 381659d..2a8af6e 100644 --- a/backend/src/graphql/resolvers/analytics.resolver.ts +++ b/backend/src/graphql/resolvers/analytics.resolver.ts @@ -163,6 +163,35 @@ const analyticsResolver = { } }, + getScholarApplyClicksRankedWithDateRange: async (_: any, { startDate, endDate }: any, { dataSources }: any) => { + const { db } = dataSources + const client = await establishConnection(db) + try { + const query = ` + SELECT scholar.scholar_id, scholar.name, scholar.email, COUNT(*) AS clicks + FROM apply_clicks + JOIN scholar ON apply_clicks.scholar_id = scholar.scholar_id + WHERE apply_clicks.click_time BETWEEN $1 AND $2 + GROUP BY scholar.scholar_id, scholar.name, scholar.email + ORDER BY clicks DESC + ` + const resp = await client.query(query, [startDate, endDate]) + console.log(resp.rows) + const formattedRows = resp.rows.map((row: any) => ({ + scholarId: row.scholar_id, + apply_count: parseInt(row.clicks), + scholarName: row.name, + scholarEmail: row.email, + })) + return formattedRows + } catch (err) { + console.error(err) + throw new Error('Failed to fetch scholar job clicks with date range') + } finally { + client.release() + } + }, + getScholarJobClicksRanked: async ( _: any, args: any, @@ -195,6 +224,35 @@ const analyticsResolver = { } }, + getScholarJobClicksRankedWithDateRange: async (_: any, { startDate, endDate }: any, { dataSources }: any) => { + const { db } = dataSources + const client = await establishConnection(db) + try { + const query = ` + SELECT scholar.scholar_id, scholar.name, scholar.email, COUNT(*) AS clicks + FROM job_clicks + JOIN scholar ON job_clicks.scholar_id = scholar.scholar_id + WHERE job_clicks.click_time BETWEEN $1 AND $2 + GROUP BY scholar.scholar_id, scholar.name, scholar.email + ORDER BY clicks DESC + ` + const resp = await client.query(query, [startDate, endDate]) + console.log(resp.rows) + const formattedRows = resp.rows.map((row: any) => ({ + scholarId: row.scholar_id, + job_count: parseInt(row.clicks), + scholarName: row.name, + scholarEmail: row.email, + })) + return formattedRows + } catch (err) { + console.error(err) + throw new Error('Failed to fetch scholar job clicks with date range') + } finally { + client.release() + } + }, + getScholarClicksBySchool: async ( _: any, args: any, @@ -224,6 +282,32 @@ const analyticsResolver = { } }, + getScholarClicksBySchoolWithDateRange: async (_: any, { startDate, endDate }: any, { dataSources }: any) => { + const { db } = dataSources + const client = await establishConnection(db) + try { + const query = ` + SELECT school, COUNT(*) AS scholar_click_count + FROM job_clicks JOIN scholar ON job_clicks.scholar_id = scholar.scholar_id + WHERE job_clicks.click_time BETWEEN $1 AND $2 + GROUP BY school + ORDER BY scholar_click_count DESC + ` + const resp = await client.query(query, [startDate, endDate]) + console.log(resp.rows) + const formattedRows = resp.rows.map((row: any) => ({ + school: row.school, + scholar_click_count: parseInt(row.scholar_click_count), + })) + return formattedRows + } catch (err) { + console.error('Error executing query:', err) + throw new Error('Failed to get scholar clicks by school with date range') + } finally { + client.release() + } + }, + getScholarEmployerClicksRanked: async ( _: any, args: any, @@ -256,6 +340,35 @@ const analyticsResolver = { } }, + getScholarEmployerClicksRankedWithDateRange: async (_: any, { startDate, endDate }: any, { dataSources }: any) => { + const { db } = dataSources + const client = await establishConnection(db) + try { + const query = ` + SELECT scholar.scholar_id, scholar.name, scholar.email, COUNT(*) AS clicks + FROM employer_clicks + JOIN scholar ON employer_clicks.scholar_id = scholar.scholar_id + WHERE employer_clicks.click_time BETWEEN $1 AND $2 + GROUP BY scholar.scholar_id, scholar.name, scholar.email + ORDER BY clicks DESC + ` + const resp = await client.query(query, [startDate, endDate]) + console.log(resp.rows) + const formattedRows = resp.rows.map((row: any) => ({ + scholarId: row.scholar_id, + employer_count: parseInt(row.clicks), + scholarName: row.name, + scholarEmail: row.email, + })) + return formattedRows + } catch (err) { + console.error(err) + throw new Error('Failed to fetch scholar employer clicks with date range') + } finally { + client.release() + } + }, + getJobClicks: async (_: any, args: any, { dataSources }: any) => { const { db } = dataSources; const client = await establishConnection(db); @@ -435,6 +548,33 @@ const analyticsResolver = { client.release(); } }, + + getJobTagRankingByClicksWithDateRange: async (_: any, { startDate, endDate }: any, { dataSources }: any) => { + const { db } = dataSources + const client = await establishConnection(db) + try { + const query = ` + SELECT unnest(tags) AS tag, COUNT(*) AS click_count + FROM job_clicks + JOIN job ON job_clicks.job_id = job.job_id + WHERE tags IS NOT NULL and click_time BETWEEN $1 AND $2 + GROUP BY tag + ORDER BY click_count DESC + ` + const resp = await client.query(query, [startDate, endDate]) + const formattedRows = resp.rows.map((row: any) => ({ + tag: row.tag, + click_count: parseInt(row.click_count), + })) + return formattedRows + } catch (err) { + console.error('Error executing query:', err) + throw new Error('Failed to get job tag ranking by clicks with date range') + } finally { + client.release() + } + }, + getApplyClicksForScholar: async ( _: any, { scholarId }: any, @@ -706,6 +846,33 @@ const analyticsResolver = { } }, + getEmployerJobPostingsRankingWithDateRange: async (_: any, { startDate, endDate }: any, { dataSources }: any) => { + const { db } = dataSources + const client = await establishConnection(db) + try { + const query = ` + SELECT employer.name, employer.employer_id, COUNT(*) AS job_click_count + FROM job JOIN job_clicks ON job.job_id = job_clicks.job_id JOIN employer ON job.employer_id = employer.employer_id + WHERE live = true AND job_clicks.click_time BETWEEN $1 AND $2 + GROUP BY employer.employer_id + ORDER BY job_click_count DESC + ` + const resp = await client.query(query, [startDate, endDate]) + console.log(resp.rows) + const formattedRows = resp.rows.map((row: any) => ({ + employerName: row.name, + employerId: row.employer_id, + job_posting_click_count: parseInt(row.job_click_count), + })) + return formattedRows + } catch (err) { + console.error('Error executing query:', err) + throw new Error('Failed to get employer job postings ranking with date range') + } finally { + client.release() + } + }, + getNumDaysSinceLastJobPostByEmployer: async ( _: any, args: any, @@ -1077,6 +1244,8 @@ const analyticsResolver = { } catch (err) { console.error("Error executing query:", err); throw new Error("Failed to get custom analytics"); + } finally { + client.release(); } }, }, diff --git a/backend/src/graphql/typeDefs/analytics.typedef.ts b/backend/src/graphql/typeDefs/analytics.typedef.ts index a0ad49d..04ac308 100644 --- a/backend/src/graphql/typeDefs/analytics.typedef.ts +++ b/backend/src/graphql/typeDefs/analytics.typedef.ts @@ -1,8 +1,7 @@ -import {gql} from "apollo-server-lambda"; +import { gql } from 'apollo-server-lambda' export const analyticsTypeDefs = gql` - - type Query { + type Query { getJobClicks: [JobClick] getApplyClicks: [ApplyClicks] getEmployerClicks: [EmployerClick] @@ -10,17 +9,23 @@ export const analyticsTypeDefs = gql` getEmployerClicksRanked: [RankedEmployerClick] getJobTagRanking: [JobTagRanking] getJobTagRankingByClicks: [JobTagRankingByClick] + getJobTagRankingByClicksWithDateRange(startDate: Date, endDate: Date): [JobTagRankingByClick] getJobLocationRanking: [JobLocationRanking] getJobDeadlineRankingByMonth: [JobDeadlineRanking] getScholarsRankedByMajor: [MajorRanking] getScholarsRankedByYear: [YearRanking] getPercentageOfScholarsWithAllowedNotifications: Int getScholarApplyClicksRanked: [ApplyClickRank] + getScholarApplyClicksRankedWithDateRange(startDate: Date, endDate: Date): [ApplyClickRank] getScholarJobClicksRanked: [JobClickRank] + getScholarJobClicksRankedWithDateRange(startDate: Date, endDate: Date): [JobClickRank] getScholarEmployerClicksRanked: [EmployerClickRank] + getScholarEmployerClicksRankedWithDateRange(startDate: Date, endDate: Date): [EmployerClickRank] getJobClicksRankedByApply: [RankedJobClick] getScholarClicksBySchool: [ScholarClicksBySchool] + getScholarClicksBySchoolWithDateRange(startDate: Date, endDate: Date): [ScholarClicksBySchool] getEmployerJobPostingsRanking: [EmployerJobPostingRank] + getEmployerJobPostingsRankingWithDateRange(startDate: Date, endDate: Date): [EmployerJobPostingRank] getNumDaysSinceLastJobPostByEmployer: [EmployerLastJobPost] getMostPopularJobTagsByEmployer: [EmployerJobTagRanking] getJobClicksForScholar(scholarId: Int): [ScholarJobClicks] @@ -38,166 +43,166 @@ export const analyticsTypeDefs = gql` getNumberOfActiveScholars: Int getNumberOfAllowedScholars: Int getClicksCustomAnalytics(startDate: Date, endDate: Date, interval: String, clickType: String): [CustomAnalytics] - } + } - type Mutation { + type Mutation { logJobClick(scholarId: Int!, jobId: Int!): JobClick logEmployerClick(scholarId: Int!, employerId: Int!): EmployerClick logApplyClick(scholarId: Int!, jobId: Int!): ApplyClick - } + } - type CustomAnalytics { - date: Date - count: Int - } + type CustomAnalytics { + date: Date + count: Int + } - type ScholarJobClicks { + type ScholarJobClicks { jobId: Int clickTime: String scholarEmail: String scholarName: String jobTitle: String employerName: String - } + } - type ScholarApplyClicks { + type ScholarApplyClicks { jobId: Int clickTime: String scholarEmail: String scholarName: String jobTitle: String employerName: String - } + } - type ScholarEmployerClicks { + type ScholarEmployerClicks { employerId: Int clickTime: String scholarEmail: String scholarName: String employerName: String - } - - type ClickCount { - count: Int - } - - type JobClicksLastWeek { - scholarId: Int - jobId: Int - clickTime: String - scholarEmail: String - scholarName: String - jobTitle: String - employerName: String - } - - type ScholarJobClicks { - scholarId: Int - jobId: Int - clickTime: String - scholarEmail: String - scholarName: String - jobTitle: String - employerName: String - } - - type ScholarApplyClicks { - scholarId: Int - jobId: Int - clickTime: String - scholarEmail: String - scholarName: String - jobTitle: String - employerName: String - } - - type ScholarEmployerClicks { - scholarId: Int - employerId: Int - clickTime: String - scholarEmail: String - scholarName: String - employerName: String - } - - type ApplyClicks { - scholarId: Int - jobId: Int - clickTime: String - scholarEmail: String - scholarName: String - jobTitle: String - employerName: String - } - - type ApplyClick { + } + + type ClickCount { + count: Int + } + + type JobClicksLastWeek { + scholarId: Int + jobId: Int + clickTime: String + scholarEmail: String + scholarName: String + jobTitle: String + employerName: String + } + + type ScholarJobClicks { scholarId: Int jobId: Int clickTime: String - } + scholarEmail: String + scholarName: String + jobTitle: String + employerName: String + } + + type ScholarApplyClicks { + scholarId: Int + jobId: Int + clickTime: String + scholarEmail: String + scholarName: String + jobTitle: String + employerName: String + } - type EmployerJobTagRanking { + type ScholarEmployerClicks { + scholarId: Int + employerId: Int + clickTime: String + scholarEmail: String + scholarName: String + employerName: String + } + + type ApplyClicks { + scholarId: Int + jobId: Int + clickTime: String + scholarEmail: String + scholarName: String + jobTitle: String + employerName: String + } + + type ApplyClick { + scholarId: Int + jobId: Int + clickTime: String + } + + type EmployerJobTagRanking { employer: String tag: String job_count: Int - } + } - type EmployerJobPostingRank { + type EmployerJobPostingRank { employerName: String employerId: String job_posting_click_count: Int - } + } - type EmployerLastJobPost { + type EmployerLastJobPost { employerName: String employerId: String days_since_last_post: Int - } + } + + scalar Date - scalar Date - - type MajorRanking { + type MajorRanking { major: String scholar_count: Int - } + } - type ScholarClicksBySchool { + type ScholarClicksBySchool { school: String scholar_click_count: Int - } + } - type YearRanking { + type YearRanking { year: Int scholar_count: Int - } + } - type JobApplyClickRank { + type JobApplyClickRank { jobId: Int apply_count: Int - } + } - type ApplyClickRank { + type ApplyClickRank { scholarId: Int apply_count: Int scholarName: String scholarEmail: String - } + } - type JobClickRank { + type JobClickRank { scholarId: Int job_count: Int scholarName: String scholarEmail: String - } + } - type EmployerClickRank { - scholarId: Int + type EmployerClickRank { + scholarId: Int employer_count: Int scholarName: String scholarEmail: String - } + } - type JobClick { + type JobClick { employerId: Int jobId: Int jobTitle: String @@ -205,54 +210,54 @@ export const analyticsTypeDefs = gql` clickTime: String scholarName: String scholarEmail: String - } + } - type RankedJobClick { + type RankedJobClick { employerId: Int jobId: Int jobTitle: String employerName: String click_count: Int - } + } - type RankedEmployerClick { + type RankedEmployerClick { employerId: Int employerName: String click_count: Int - } + } - type ApplyClick { + type ApplyClick { scholarId: Int jobId: Int clickTime: String - } + } - type JobTagRanking { + type JobTagRanking { tag: String job_count: Int - } + } - type JobTagRankingByClick { + type JobTagRankingByClick { tag: String click_count: Int - } + } - type JobLocationRanking { + type JobLocationRanking { location: String job_count: Int - } + } - type JobDeadlineRanking { + type JobDeadlineRanking { month: String job_count: Int - } - - type EmployerClick { + } + + type EmployerClick { scholarId: Int employerId: Int employerName: String clickTime: String scholarName: String scholarEmail: String - } -` \ No newline at end of file + } +` diff --git a/frontend/graphql/queries/analyticsQueries.ts b/frontend/graphql/queries/analyticsQueries.ts index c891cb0..a96c796 100644 --- a/frontend/graphql/queries/analyticsQueries.ts +++ b/frontend/graphql/queries/analyticsQueries.ts @@ -1,207 +1,207 @@ -import { gql } from '@apollo/client'; +import { gql } from '@apollo/client' // Query for getting job clicks export const GET_JOB_CLICKS = gql` - query GetJobClicks { - getJobClicks { - jobTitle - employerId - employerName - jobId - clickTime - scholarName - scholarEmail - } - } -`; + query GetJobClicks { + getJobClicks { + jobTitle + employerId + employerName + jobId + clickTime + scholarName + scholarEmail + } + } +` // Query for getting apply clicks export const GET_APPLY_CLICKS = gql` - query GetApplyClicks { - getApplyClicks { - scholarId - jobId - clickTime - scholarName - scholarEmail - jobTitle - employerName - } - } -`; + query GetApplyClicks { + getApplyClicks { + scholarId + jobId + clickTime + scholarName + scholarEmail + jobTitle + employerName + } + } +` // Query for getting employer clicks export const GET_EMPLOYER_CLICKS = gql` - query GetEmployerClicks { - getEmployerClicks { - scholarId - employerId - employerName - clickTime - scholarName - scholarEmail - } - } -`; + query GetEmployerClicks { + getEmployerClicks { + scholarId + employerId + employerName + clickTime + scholarName + scholarEmail + } + } +` // Query for getting ranked job clicks export const GET_JOB_CLICKS_RANKED = gql` - query GetJobClicksRanked { - getJobClicksRanked { - employerId - jobId - jobTitle - employerName - click_count - } - } -`; + query GetJobClicksRanked { + getJobClicksRanked { + employerId + jobId + jobTitle + employerName + click_count + } + } +` // Query for getting job tag rankings export const GET_JOB_TAG_RANKING = gql` - query GetJobTagRanking { - getJobTagRanking { - tag - job_count - } + query GetJobTagRanking { + getJobTagRanking { + tag + job_count } -`; + } +` // Query for getting job tag ranking by clicks export const GET_JOB_TAG_RANKING_BY_CLICKS = gql` - query GetJobTagRankingByClicks { - getJobTagRankingByClicks { - tag - click_count - } + query GetJobTagRankingByClicks { + getJobTagRankingByClicks { + tag + click_count } -`; + } +` // Query for getting job location ranking export const GET_JOB_LOCATION_RANKING = gql` - query GetJobLocationRanking { - getJobLocationRanking { - location - job_count - } + query GetJobLocationRanking { + getJobLocationRanking { + location + job_count } -`; + } +` // Query for getting job deadline ranking by month export const GET_JOB_DEADLINE_RANKING_BY_MONTH = gql` - query GetJobDeadlineRankingByMonth { - getJobDeadlineRankingByMonth { - month - job_count - } + query GetJobDeadlineRankingByMonth { + getJobDeadlineRankingByMonth { + month + job_count } -`; + } +` // Query for getting scholars ranked by major export const GET_SCHOLARS_RANKED_BY_MAJOR = gql` - query GetScholarsRankedByMajor { - getScholarsRankedByMajor { - major - scholar_count - } + query GetScholarsRankedByMajor { + getScholarsRankedByMajor { + major + scholar_count } -`; + } +` // Query for getting scholars ranked by year export const GET_SCHOLARS_RANKED_BY_YEAR = gql` - query GetScholarsRankedByYear { - getScholarsRankedByYear { - year - scholar_count - } + query GetScholarsRankedByYear { + getScholarsRankedByYear { + year + scholar_count } -`; + } +` // Query for getting the percentage of scholars with allowed notifications export const GET_PERCENTAGE_OF_SCHOLARS_WITH_ALLOWED_NOTIFICATIONS = gql` - query GetPercentageOfScholarsWithAllowedNotifications { - getPercentageOfScholarsWithAllowedNotifications - } -`; + query GetPercentageOfScholarsWithAllowedNotifications { + getPercentageOfScholarsWithAllowedNotifications + } +` // Query for getting scholar apply clicks ranked export const GET_SCHOLAR_APPLY_CLICKS_RANKED = gql` - query GetScholarApplyClicksRanked { - getScholarApplyClicksRanked { - scholarId - apply_count - scholarName - scholarEmail - } - } -`; + query GetScholarApplyClicksRanked { + getScholarApplyClicksRanked { + scholarId + apply_count + scholarName + scholarEmail + } + } +` // Query for getting scholar job clicks ranked export const GET_SCHOLAR_JOB_CLICKS_RANKED = gql` - query GetScholarJobClicksRanked { - getScholarJobClicksRanked { - scholarId - job_count - scholarName - scholarEmail - } - } -`; + query GetScholarJobClicksRanked { + getScholarJobClicksRanked { + scholarId + job_count + scholarName + scholarEmail + } + } +` // Query for getting scholar employer clicks ranked export const GET_SCHOLAR_EMPLOYER_CLICKS_RANKED = gql` - query GetScholarEmployerClicksRanked { - getScholarEmployerClicksRanked { - scholarId - employer_count - scholarName - scholarEmail - } - } -`; + query GetScholarEmployerClicksRanked { + getScholarEmployerClicksRanked { + scholarId + employer_count + scholarName + scholarEmail + } + } +` // Query for getting employer job postings ranking export const GET_EMPLOYER_JOB_POSTINGS_RANKING = gql` - query GetEmployerJobPostingsRanking { - getEmployerJobPostingsRanking { - employerName - employerId - job_posting_click_count - } - } -`; + query GetEmployerJobPostingsRanking { + getEmployerJobPostingsRanking { + employerName + employerId + job_posting_click_count + } + } +` // Query for getting the number of days since last job post by employer export const GET_NUM_DAYS_SINCE_LAST_JOB_POST_BY_EMPLOYER = gql` - query GetNumDaysSinceLastJobPostByEmployer { - getNumDaysSinceLastJobPostByEmployer { - employerName - employerId - days_since_last_post - } - } -`; + query GetNumDaysSinceLastJobPostByEmployer { + getNumDaysSinceLastJobPostByEmployer { + employerName + employerId + days_since_last_post + } + } +` // Query for getting the most popular job tags by employer export const GET_MOST_POPULAR_JOB_TAGS_BY_EMPLOYER = gql` - query GetMostPopularJobTagsByEmployer { - getMostPopularJobTagsByEmployer { - employer - tag - job_count - } - } -`; + query GetMostPopularJobTagsByEmployer { + getMostPopularJobTagsByEmployer { + employer + tag + job_count + } + } +` // Query for getting scholar clicks by school export const GET_SCHOLAR_CLICKS_BY_SCHOOL = gql` - query GetScholarClicksBySchool { - getScholarClicksBySchool { - school - scholar_click_count - } + query GetScholarClicksBySchool { + getScholarClicksBySchool { + school + scholar_click_count } -`; + } +` export const GET_ALL_CLICK_COUNTS = gql` query GetAllClickCounts { @@ -233,7 +233,7 @@ export const GET_ALL_CLICK_COUNTS = gql` count } } -`; +` export const GET_JOB_CLICKS_FOR_SCHOLAR = gql` query GetJobClicksForScholar($scholarId: Int!) { @@ -246,8 +246,7 @@ export const GET_JOB_CLICKS_FOR_SCHOLAR = gql` employerName } } -`; - +` export const GET_APPLY_CLICKS_FOR_SCHOLAR = gql` query GetApplyClicksForScholar($scholarId: Int!) { @@ -260,7 +259,7 @@ export const GET_APPLY_CLICKS_FOR_SCHOLAR = gql` employerName } } -`; +` export const GET_EMPLOYER_CLICKS_FOR_SCHOLAR = gql` query GetEmployerClicksForScholar($scholarId: Int!) { @@ -272,25 +271,186 @@ export const GET_EMPLOYER_CLICKS_FOR_SCHOLAR = gql` employerName } } -`; +` export const GET_NUMBER_OF_ACTIVE_SCHOLARS = gql` query GetNumberOfActiveScholars { getNumberOfActiveScholars } -`; +` export const GET_NUMBER_OF_ALLOWED_SCHOLARS = gql` query GetNumberOfAllowedScholars { getNumberOfAllowedScholars } -`; +` export const GET_CLICKS_CUSTOM_ANALYTICS = gql` - query GetClicksCustomAnalytics($startDate: Date!, $endDate: Date!, $interval: String!, $clickType: String!) { - getClicksCustomAnalytics(startDate: $startDate, endDate: $endDate, interval: $interval, clickType: $clickType) { - date - count - } + query GetClicksCustomAnalytics($startDate: Date!, $endDate: Date!, $interval: String!, $clickType: String!) { + getClicksCustomAnalytics(startDate: $startDate, endDate: $endDate, interval: $interval, clickType: $clickType) { + date + count + } + } +` + +export const GET_ANALYTICS_DASHBOARD_DATA = gql` + query GetAnalyticsDashboardData($startDate: Date!, $endDate: Date!) { + jobTagsRankedByJobCount: getJobTagRanking { + tag + job_count + } + jobLocationsRankedByJobCount: getJobLocationRanking { + location + job_count + } + jobDeadlinesAsMonthRankedByJobCount: getJobDeadlineRankingByMonth { + month + job_count } - `; + daysSinceLastJobPostByEmployer: getNumDaysSinceLastJobPostByEmployer { + employerName + days_since_last_post + } + scholarsRankedByMajor: getScholarsRankedByMajor { + major + scholar_count + } + scholarsRankedByYear: getScholarsRankedByYear { + year + scholar_count + } + jobTagClicks: getJobTagRankingByClicksWithDateRange(startDate: $startDate, endDate: $endDate) { + tag + click_count + } + employerJobPostingClicks: getEmployerJobPostingsRankingWithDateRange(startDate: $startDate, endDate: $endDate) { + employerName + job_posting_click_count + } + scholarClicksBySchool: getScholarClicksBySchoolWithDateRange(startDate: $startDate, endDate: $endDate) { + school + scholar_click_count + } + scholarJobClicks: getScholarJobClicksRankedWithDateRange(startDate: $startDate, endDate: $endDate) { + scholarName + job_count + } + scholarApplyClicks: getScholarApplyClicksRankedWithDateRange(startDate: $startDate, endDate: $endDate) { + scholarName + apply_count + } + scholarEmployerClicks: getScholarEmployerClicksRankedWithDateRange(startDate: $startDate, endDate: $endDate) { + scholarName + employer_count + } + jobClicksDaily: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "daily" + clickType: "job" + ) { + date + count + } + jobClicksWeekly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "weekly" + clickType: "job" + ) { + date + count + } + jobClicksMonthly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "monthly" + clickType: "job" + ) { + date + count + } + jobClicksYearly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "yearly" + clickType: "job" + ) { + date + count + } + applyClicksDaily: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "daily" + clickType: "apply" + ) { + date + count + } + applyClicksWeekly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "weekly" + clickType: "apply" + ) { + date + count + } + applyClicksMonthly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "monthly" + clickType: "apply" + ) { + date + count + } + applyClicksYearly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "yearly" + clickType: "apply" + ) { + date + count + } + employerClicksDaily: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "daily" + clickType: "employer" + ) { + date + count + } + employerClicksWeekly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "weekly" + clickType: "employer" + ) { + date + count + } + employerClicksMonthly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "monthly" + clickType: "employer" + ) { + date + count + } + employerClicksYearly: getClicksCustomAnalytics( + startDate: $startDate + endDate: $endDate + interval: "yearly" + clickType: "employer" + ) { + date + count + } + } +` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 951fc43..4f97330 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,7 @@ "@types/file-saver": "^2.0.7", "@vercel/analytics": "^1.0.1", "bcrypt": "^5.1.0", - "chart.js": "^4.3.0", + "chart.js": "^4.4.4", "dayjs": "^1.11.6", "dotenv": "^16.0.3", "embla-carousel-react": "^7.0.5", @@ -59,6 +59,7 @@ "react": "18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "18.2.0", + "react-google-charts": "^4.0.1", "react-hook-form": "^7.41.3", "react-icons": "^4.7.1", "react-responsive": "^9.0.2", @@ -2276,14 +2277,14 @@ } }, "node_modules/chart.js": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.1.tgz", - "integrity": "sha512-QHuISG3hTJ0ftq0I0f5jqH9mNVO9bqG8P+zvMOVslgKajQVvFEX7QAhYNJ+QEmw+uYTwo8XpTimaB82oeTWjxw==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz", + "integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==", "dependencies": { "@kurkle/color": "^0.3.0" }, "engines": { - "pnpm": ">=7" + "pnpm": ">=8" } }, "node_modules/chownr": { @@ -5951,6 +5952,15 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-google-charts": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-google-charts/-/react-google-charts-4.0.1.tgz", + "integrity": "sha512-V/hcMcNuBgD5w49BYTUDye+bUKaPmsU5vy/9W/Nj2xEeGn+6/AuH9IvBkbDcNBsY00cV9OeexdmgfI5RFHgsXQ==", + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/react-hook-form": { "version": "7.45.2", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz", @@ -8962,9 +8972,9 @@ } }, "chart.js": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.1.tgz", - "integrity": "sha512-QHuISG3hTJ0ftq0I0f5jqH9mNVO9bqG8P+zvMOVslgKajQVvFEX7QAhYNJ+QEmw+uYTwo8XpTimaB82oeTWjxw==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz", + "integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==", "requires": { "@kurkle/color": "^0.3.0" } @@ -11655,6 +11665,12 @@ "prop-types": "^15.8.1" } }, + "react-google-charts": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-google-charts/-/react-google-charts-4.0.1.tgz", + "integrity": "sha512-V/hcMcNuBgD5w49BYTUDye+bUKaPmsU5vy/9W/Nj2xEeGn+6/AuH9IvBkbDcNBsY00cV9OeexdmgfI5RFHgsXQ==", + "requires": {} + }, "react-hook-form": { "version": "7.45.2", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 48b9f22..99e0e22 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,7 +42,7 @@ "@types/file-saver": "^2.0.7", "@vercel/analytics": "^1.0.1", "bcrypt": "^5.1.0", - "chart.js": "^4.3.0", + "chart.js": "^4.4.4", "dayjs": "^1.11.6", "dotenv": "^16.0.3", "embla-carousel-react": "^7.0.5", @@ -60,6 +60,7 @@ "react": "18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "18.2.0", + "react-google-charts": "^4.0.1", "react-hook-form": "^7.41.3", "react-icons": "^4.7.1", "react-responsive": "^9.0.2", diff --git a/frontend/pages/Admin.tsx b/frontend/pages/Admin.tsx index babb739..826b049 100644 --- a/frontend/pages/Admin.tsx +++ b/frontend/pages/Admin.tsx @@ -14,6 +14,7 @@ import EditJobButton from "../src/components/admin/EditJobButton"; import { Analytics } from "@vercel/analytics/react"; import AnalyticsPage from "./Analytics"; import AnalyticsButton from "../src/components/admin/AnalyticsButton"; +import DashboardButton from "../src/components/admin/DashboardButton"; // To ensure unauthenticated people don't access @@ -75,7 +76,8 @@ export default function Admin() {

Analytics

- + +
diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx new file mode 100644 index 0000000..1aa761b --- /dev/null +++ b/frontend/pages/Dashboard.tsx @@ -0,0 +1,297 @@ +import { useLazyQuery } from '@apollo/client' +import { useEffect, useState } from 'react' +import { GET_ANALYTICS_DASHBOARD_DATA } from '../graphql/queries/analyticsQueries' +import DashboardClickChart from '../src/components/admin/DashboardClickChart' +import DashboardDateLimitInput from '../src/components/admin/DashboardDateLimitInput' +import DashboardDateLimitSubmit from '../src/components/admin/DashboardDateLimitSubmit' +import DashboardErrorMessage from '../src/components/admin/DashboardErrorMessage' +import DashboardTitleRow from '../src/components/admin/DashboardTitleRow' +import DashboardTwoItemTable from '../src/components/admin/DashboardTwoItemTable' +import FancySpinner from '../src/components/admin/FancySpinner' +import Navbar from '../src/components/general/Navbar' +import styles from '../styles/Dashboard.module.css' + +export default function Dashboard() { + const currentDateObj = new Date() + const currentDate = currentDateObj.toISOString().split('T')[0] + const currentYear = currentDateObj.getFullYear() + const [startDate, setStartDate] = useState(`${currentYear}-01-01`) + const [endDate, setEndDate] = useState(`${currentYear}-12-31`) + const [fetchedStartDate, setFetchedStartDate] = useState(startDate) + const [fetchedEndDate, setFetchedEndDate] = useState(endDate) + const [dateRangeFormError, setDateRangeFormError] = useState('') + + const [ + getPageData, + { previousData: previousPageData, data: pageData, loading: pageLoading, error: pageError, called: pageCalled }, + ] = useLazyQuery(GET_ANALYTICS_DASHBOARD_DATA, { + fetchPolicy: 'cache-and-network', + onCompleted: () => { + setFetchedStartDate(startDate) + setFetchedEndDate(endDate) + }, + }) + useEffect(() => { + if (!pageCalled) { + getPageData({ variables: { startDate, endDate } }) + } + }, [endDate, getPageData, pageCalled, startDate]) + + const hasDateRangeFormError = () => { + setDateRangeFormError('') + + const validDateRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/ + if (!validDateRegex.test(startDate)) { + setDateRangeFormError('Start date is invalid.') + return true + } + if (!validDateRegex.test(endDate)) { + setDateRangeFormError('End date is invalid.') + return true + } + + return false + } + const submitGetPageDataForm = () => { + if (hasDateRangeFormError()) { + return + } + + getPageData({ variables: { startDate, endDate } }) + } + + let DashboardContent = ( +
+ +
+ ) + const currentPageData = pageData ? pageData : previousPageData // shows old data while new date range is being fetched + if (currentPageData) { + const jobTagsRankedByJobCount: [{ tag: string; job_count: string }] = currentPageData['jobTagsRankedByJobCount'] + const jobTagsRankedByJobCountTableBodyData = jobTagsRankedByJobCount.map((ranking) => [ + ranking.tag, + ranking.job_count, + ]) + + const jobLocationsRankedByJobCount: [{ location: string; job_count: string }] = + currentPageData['jobLocationsRankedByJobCount'] + const jobLocationsRankedByJobCountTableBodyData = jobLocationsRankedByJobCount.map((ranking) => [ + ranking.location, + ranking.job_count, + ]) + + const jobDeadlinesAsMonthRankedByJobCount: [{ month: string; job_count: string }] = + currentPageData['jobDeadlinesAsMonthRankedByJobCount'] + const jobDeadlinesAsMonthRankedByJobCountTableBodyData = jobDeadlinesAsMonthRankedByJobCount.map((ranking) => [ + ranking.month, + ranking.job_count, + ]) + + const daysSinceLastJobPostByEmployer: [{ employerName: string; days_since_last_post: string }] = + currentPageData['daysSinceLastJobPostByEmployer'] + const daysSinceLastJobPostByEmployerTableBodyData = daysSinceLastJobPostByEmployer.map((ranking) => [ + ranking.employerName, + ranking.days_since_last_post, + ]) + + const scholarsByMajor: [{ major: string; scholar_count: string }] = currentPageData['scholarsRankedByMajor'] + const scholarsByMajorTableBodyData = scholarsByMajor.map((ranking) => [ranking.major, ranking.scholar_count]) + + const scholarsByYear: [{ year: string; scholar_count: string }] = currentPageData['scholarsRankedByYear'] + const scholarsByYearTableBodyData = scholarsByYear.map((ranking) => [ranking.year, ranking.scholar_count]) + + // Get click counts for each main type of link + const linkTypes = ['Job', 'Apply', 'Employer'] + const intervals = ['Daily', 'Weekly', 'Monthly', 'Yearly'] + const linkTypeIntervalClickGroups = linkTypes.flatMap((type) => + intervals.map((interval) => { + return { + type, + interval, + dataKey: `${type.toLowerCase()}Clicks${interval}`, + } + }) + ) + const linkTypeClicksTableBodyData = linkTypes.map((type) => { + const dataKey = `${type.toLowerCase()}Clicks${intervals[0]}` // can be any interval + const totalClicks = currentPageData[dataKey].reduce( + ( + prev: number, + cur: { + date: string + count: number + } + ) => prev + cur.count, + 0 + ) + return [type, totalClicks] + }) + + const jobTagClicks: [{ tag: string; click_count: string }] = currentPageData['jobTagClicks'] + const jobTagClicksTableBodyData = jobTagClicks.map((ranking) => [ranking.tag, ranking.click_count]) + + const employerJobPostingClicks: [{ employerName: string; job_posting_click_count: string }] = + currentPageData['employerJobPostingClicks'] + const employerJobPostingClicksTableBodyData = employerJobPostingClicks.map((ranking) => [ + ranking.employerName, + ranking.job_posting_click_count, + ]) + + const scholarClicksBySchool: [{ school: string; scholar_click_count: string }] = + currentPageData['scholarClicksBySchool'] + const scholarClicksBySchoolTableBodyData = scholarClicksBySchool.map((ranking) => [ + ranking.school, + ranking.scholar_click_count, + ]) + + const scholarJobClicks: [{ scholarName: string; job_count: string }] = currentPageData['scholarJobClicks'] + const scholarJobClicksTableBodyData = scholarJobClicks.map((ranking) => [ranking.scholarName, ranking.job_count]) + + const scholarApplyClicks: [{ scholarName: string; apply_count: string }] = currentPageData['scholarApplyClicks'] + const scholarApplyClicksTableBodyData = scholarApplyClicks.map((ranking) => [ + ranking.scholarName, + ranking.apply_count, + ]) + + const scholarEmployerClicks: [{ scholarName: string; employer_count: string }] = + currentPageData['scholarEmployerClicks'] + const scholarEmployerClicksTableBodyData = scholarEmployerClicks.map((ranking) => [ + ranking.scholarName, + ranking.employer_count, + ]) + + DashboardContent = ( + <> +
+ +
+ + + + + + +
+
+
+ +
+ { + setStartDate(e.target.value) + hasDateRangeFormError() + }} + onFocus={hasDateRangeFormError} + onBlur={hasDateRangeFormError} + className={styles.dateLimitInput} + max={currentDate} + disabled={pageLoading} + /> + { + setEndDate(e.target.value) + hasDateRangeFormError() + }} + onFocus={hasDateRangeFormError} + onBlur={hasDateRangeFormError} + className={styles.dateLimitInput} + min={startDate} + max={currentDate} + disabled={pageLoading} + /> + + Get Clicks + + + {dateRangeFormError ?
{dateRangeFormError}
: null} +
+ } + /> + +
+ + + + + + + +
+ +
+ {linkTypeIntervalClickGroups.map((chart) => ( +
+ +
+ ))} +
+ + ) + } + + return ( + <> +
+ +
+
+ {pageError ? : DashboardContent} +
+ + ) +} diff --git a/frontend/src/components/admin/DashboardButton.tsx b/frontend/src/components/admin/DashboardButton.tsx new file mode 100644 index 0000000..4633b69 --- /dev/null +++ b/frontend/src/components/admin/DashboardButton.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import styles from "../../../styles/components/AdminPageButtons.module.css"; +import Link from "next/link"; + + +export default function DashboardButton(props:any) { + const { text, link } = props; + return ( +
+ + + +
+ ) + +} \ No newline at end of file diff --git a/frontend/src/components/admin/DashboardClickChart.tsx b/frontend/src/components/admin/DashboardClickChart.tsx new file mode 100644 index 0000000..a5b6b85 --- /dev/null +++ b/frontend/src/components/admin/DashboardClickChart.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react' +import styles from '../../../styles/components/DashboardClickChart.module.css' +import Chart from 'react-google-charts' +import Image from 'next/image' + +type ClickChartProps = { + data: [ + { + date: string + count: number + } + ] + interval: string + type: string +} + +export default function DashboardClickChart({ data, interval, type }: ClickChartProps) { + const title = `${interval} ${type} Clicks` + const clickCounts = [['Date', `${interval} ${type} Click Count`], ...data.map((item) => [item.date, item.count])] + const chartOptions = { + title, + titlePosition: 'none', + legend: 'none', + backgroundColor: 'transparent', + pointSize: 6, + chartArea: { + width: '100%', + height: '100%', + bottom: '30', + }, + vAxis: { + gridlines: { + color: '#333', + }, + minorGridlines: { + count: 0, + }, + textPosition: 'in', + textStyle: { + color: '#999', + }, + slantedText: false, + maxAlternation: 1, + format: 'short', + }, + hAxis: { + textPosition: 'out', + textStyle: { + color: '#999', + }, + slantedText: false, + maxAlternation: 1, + format: 'short', + }, + crosshair: { + trigger: 'focus', + color: 'white', + orientation: 'vertical', + }, + tooltip: { + isHtml: true, + }, + axisTitlesPosition: 'none', + } + + // Get chart PNG for printing + const [chartWrapper, setChartWrapper] = useState(null) + const [chartImageURI, setChartImageURI] = useState('') + useEffect(() => { + if (chartWrapper !== null) { + setChartImageURI(chartWrapper.getChart()?.getImageURI()) + } + }, [chartWrapper, data, interval, type]) + + return ( + <> +
+
{title}
+ {data.length ? ( + <> +
+ setChartWrapper(wrapper)} + /> +
+ {chartImageURI ? ( +
+ {`${title} +
+ ) : null} + + ) : ( +
No data
+ )} +
+ + ) +} diff --git a/frontend/src/components/admin/DashboardDateLimitInput.tsx b/frontend/src/components/admin/DashboardDateLimitInput.tsx new file mode 100644 index 0000000..5bceafb --- /dev/null +++ b/frontend/src/components/admin/DashboardDateLimitInput.tsx @@ -0,0 +1,22 @@ +import styles from '../../../styles/components/DashboardDateLimitComponents.module.css' + +type DashboardDateLimitInputProps = { + label: string + value: string + onChange: (e: React.ChangeEvent) => void + [extraProps: string]: any +} + +export default function DashboardDateLimitInput({ + label, + value, + onChange, + ...extraProps +}: DashboardDateLimitInputProps) { + return ( + + ) +} diff --git a/frontend/src/components/admin/DashboardDateLimitSubmit.tsx b/frontend/src/components/admin/DashboardDateLimitSubmit.tsx new file mode 100644 index 0000000..73f1e91 --- /dev/null +++ b/frontend/src/components/admin/DashboardDateLimitSubmit.tsx @@ -0,0 +1,23 @@ +import { MouseEventHandler, ReactNode } from 'react' +import styles from '../../../styles/components/DashboardDateLimitComponents.module.css' +import Spinner from './Spinner' + +type DashboardDateLimitSubmitProps = { + onClick: MouseEventHandler + loading: boolean + children: ReactNode + [extraProps: string]: any +} + +export default function DashboardDateLimitSubmit({ + onClick, + loading, + children, + ...extraProps +}: DashboardDateLimitSubmitProps) { + return ( + + ) +} diff --git a/frontend/src/components/admin/DashboardErrorMessage.tsx b/frontend/src/components/admin/DashboardErrorMessage.tsx new file mode 100644 index 0000000..ef4efc9 --- /dev/null +++ b/frontend/src/components/admin/DashboardErrorMessage.tsx @@ -0,0 +1,11 @@ +type DashboardErrorMessageProps = { + message: string +} + +export default function DashboardErrorMessage({ message }: DashboardErrorMessageProps) { + return ( +
+ Error: {message} +
+ ) +} diff --git a/frontend/src/components/admin/DashboardTitleRow.tsx b/frontend/src/components/admin/DashboardTitleRow.tsx new file mode 100644 index 0000000..be963cb --- /dev/null +++ b/frontend/src/components/admin/DashboardTitleRow.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from 'react' +import styles from '../../../styles/components/DashboardTitleRow.module.css' + +type DashboardTitleRowProps = { + title: string + rightComponent?: ReactElement +} + +export default function DashboardTitleRow({ title, rightComponent = <> }: DashboardTitleRowProps) { + return ( +
+

{title}

+ {rightComponent} +
+ ) +} diff --git a/frontend/src/components/admin/DashboardTwoItemTable.tsx b/frontend/src/components/admin/DashboardTwoItemTable.tsx new file mode 100644 index 0000000..957300a --- /dev/null +++ b/frontend/src/components/admin/DashboardTwoItemTable.tsx @@ -0,0 +1,37 @@ +import styles from '../../../styles/components/DashboardTwoItemTable.module.css' + +type DashboardTwoItemTableProps = { + firstHeading: string + secondHeading: string + data: Array> +} + +export default function DashboardTwoItemTable({ firstHeading, secondHeading, data }: DashboardTwoItemTableProps) { + return ( +
+ + + + + + + + + {data.length ? ( + data.map((row) => ( + + + + + )) + ) : ( + + + + )} + +
{firstHeading}{secondHeading}
{row[0]}{row[1]}
No Data +
+
+ ) +} diff --git a/frontend/src/components/admin/FancySpinner.tsx b/frontend/src/components/admin/FancySpinner.tsx new file mode 100644 index 0000000..6ea8d08 --- /dev/null +++ b/frontend/src/components/admin/FancySpinner.tsx @@ -0,0 +1,9 @@ +import styles from '../../../styles/components/FancySpinner.module.css' + +type SpinnerProps = { + size?: number +} + +export default function FancySpinner({ size = 100 }: SpinnerProps) { + return
+} diff --git a/frontend/src/components/admin/Spinner.tsx b/frontend/src/components/admin/Spinner.tsx new file mode 100644 index 0000000..e7aa0d7 --- /dev/null +++ b/frontend/src/components/admin/Spinner.tsx @@ -0,0 +1,10 @@ +import styles from '../../../styles/components/Spinner.module.css' + +type SpinnerProps = { + size?: string + thickness?: string +} + +export default function Spinner({ size = '1em', thickness = '0.2em' }: SpinnerProps) { + return
+} diff --git a/frontend/styles/Dashboard.module.css b/frontend/styles/Dashboard.module.css new file mode 100644 index 0000000..5a2fe4d --- /dev/null +++ b/frontend/styles/Dashboard.module.css @@ -0,0 +1,84 @@ +.wrapper { + padding-left: 4rem; + padding-right: 4rem; + padding-top: 2rem; + padding-bottom: 2rem; + max-width: 1554px; + margin: 0 auto; +} + +.center { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.dateLimitsForm { + display: flex; + flex-flow: wrap; + justify-content: center; + align-items: end; + gap: 1rem; +} + +.dateLimitsFormError { + color: #ef8b8b; + padding: 1rem 0; + text-align: center; +} + +.quickStats { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + margin-bottom: 3rem; +} + +.printSection { + break-inside: avoid; +} + +.chartContainer { + margin-bottom: 3rem; +} + +.chartContainer:last-child { + margin-bottom: 0; +} + +@media screen and (min-width: 1064px) { + .quickStats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media screen and (min-width: 768px) { + .dateLimitsForm { + justify-content: right; + align-items: end; + } +} + +@media print { + .quickStats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .printSection:not(:first-child) { + margin-top: 3rem; + } + + .wrapper { + color: black; + } + + .noPrint, + .dateLimitsForm { + display: none; + } +} diff --git a/frontend/styles/components/DashboardClickChart.module.css b/frontend/styles/components/DashboardClickChart.module.css new file mode 100644 index 0000000..6bca284 --- /dev/null +++ b/frontend/styles/components/DashboardClickChart.module.css @@ -0,0 +1,55 @@ +.chart { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: 2rem 2rem 1rem; + border: #333 1px solid; + border-radius: 1rem; + break-inside: avoid; +} + +.chartTitle { + font-size: 1.6rem; + margin-bottom: 2rem; + font-weight: bold; +} + +.googleChartContainer { + width: 100%; + height: 450px; +} + +.googleChartContainer :global(div.google-visualization-tooltip) { + background-color: #222; + border: #555 2px solid; + border-radius: 0.5rem; + box-shadow: none; + -webkit-box-shadow: none; +} + +.googleChartContainer :global(div.google-visualization-tooltip > ul > li > span) { + color: white !important; +} + +.noDataMessage { + padding-bottom: 1rem; + font-style: italic; +} + +.chartImageContainer { + display: none; + position: relative; + width: 100%; + height: 300px; +} + +@media print { + .googleChartContainer { + display: none; + } + + .chartImageContainer { + display: block; + } +} diff --git a/frontend/styles/components/DashboardDateLimitComponents.module.css b/frontend/styles/components/DashboardDateLimitComponents.module.css new file mode 100644 index 0000000..a3dd005 --- /dev/null +++ b/frontend/styles/components/DashboardDateLimitComponents.module.css @@ -0,0 +1,82 @@ +.dateLimitInputContainer { + display: flex; + flex-direction: column; + align-items: center; +} + +.dateLimitInputLabel, +.dateLimitInput, +.dateLimitSubmit { + color: inherit; + font-family: inherit; + font-size: 0.85rem; +} + +.dateLimitInput:disabled, +.dateLimitSubmit:disabled, +.dateLimitSubmit:disabled:hover { + cursor: not-allowed; +} + +.dateLimitInput, +.dateLimitSubmit { + padding: 0 1rem; + height: 2.2rem; + transition-duration: 150ms; + transition-property: border-color, opacity; +} + +.dateLimitInputLabel { + margin-bottom: 0.25rem; +} + +.dateLimitInput { + background-color: transparent; + border-width: 1px; + border-style: solid; + border-radius: 0.5rem; + min-width: 9rem; + cursor: text; +} + +.dateLimitInput, +.dateLimitInput:disabled:hover { + border-color: #333; +} + +.dateLimitInput:hover { + border-color: #666; +} + +.dateLimitInput::-webkit-calendar-picker-indicator { + cursor: pointer; +} + +.dateLimitSubmit { + min-width: 9rem; + height: 2.2rem; + padding: 0 2rem; + cursor: pointer; + background-color: #333; + border-width: 2px; + border-style: solid; + border-radius: 0.25rem; + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; +} + +.dateLimitSubmit, +.dateLimitSubmit:disabled:hover { + border-color: #555; +} + +.dateLimitSubmit:hover { + border-color: #888; +} + +.dateLimitInput:disabled, +.dateLimitSubmit:disabled { + opacity: 0.7; +} diff --git a/frontend/styles/components/DashboardTitleRow.module.css b/frontend/styles/components/DashboardTitleRow.module.css new file mode 100644 index 0000000..c53a923 --- /dev/null +++ b/frontend/styles/components/DashboardTitleRow.module.css @@ -0,0 +1,23 @@ +.titleRow { + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + gap: 2rem; + margin-bottom: 2rem; +} + +.title { + margin: 0; + font-size: 1.8rem; + font-weight: bold; + text-align: left; +} + +@media screen and (min-width: 1064px) { + .titleRow { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} diff --git a/frontend/styles/components/DashboardTwoItemTable.module.css b/frontend/styles/components/DashboardTwoItemTable.module.css new file mode 100644 index 0000000..d5b5f98 --- /dev/null +++ b/frontend/styles/components/DashboardTwoItemTable.module.css @@ -0,0 +1,75 @@ +.dataTable { + overflow-x: auto; + display: flex; + align-items: stretch; + break-inside: avoid; +} + +.dataTable table { + display: flex; + flex-direction: column; + width: 100%; +} + +.dataTable thead, +.dataTable tbody { + display: block; + width: 100%; +} + +.dataTable tr { + display: flex; + justify-content: space-between; + align-items: center; + column-gap: 1rem; +} + +.dataTable thead tr { + border: #333 1px solid; + border-radius: 1rem 1rem 0 0; + padding: 1.5rem 1rem; +} + +.dataTable tbody { + border: #333 1px solid; + border-top: none; + border-radius: 0 0 1rem 1rem; + padding: 1rem; + max-height: 300px; + overflow-y: auto; + flex-grow: 1; +} + +.dataTable tbody tr { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.dataTable tbody tr:last-child { + padding-bottom: 0; +} + +.dataTable tbody tr:first-child { + padding-top: 0; +} + +.dataTable .noDataCell { + font-style: italic; +} + +@media print { + .dataTable thead tr { + padding: 0.5rem; + } + + .dataTable tbody { + max-height: 290px; + padding: 0.5rem; + overflow: hidden; + } + + .dataTable tbody tr { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + } +} diff --git a/frontend/styles/components/FancySpinner.module.css b/frontend/styles/components/FancySpinner.module.css new file mode 100644 index 0000000..dd9b51b --- /dev/null +++ b/frontend/styles/components/FancySpinner.module.css @@ -0,0 +1,74 @@ +.loader { + transform: rotateZ(45deg); + perspective: 1000px; + border-radius: 50%; + width: 48px; + height: 48px; + color: #fff; +} + +.loader:before, +.loader:after { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: inherit; + height: inherit; + border-radius: 50%; + transform: rotateX(70deg); + animation: 1s spin linear infinite; +} + +.loader:after { + color: #999; + transform: rotateY(70deg); + animation-delay: 0.4s; +} + +@keyframes rotate { + 0% { + transform: translate(-50%, -50%) rotateZ(0deg); + } + 100% { + transform: translate(-50%, -50%) rotateZ(360deg); + } +} + +@keyframes rotateccw { + 0% { + transform: translate(-50%, -50%) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(-360deg); + } +} + +@keyframes spin { + 0%, + 100% { + box-shadow: 0.5em 0px 0 0px currentcolor; + } + 12% { + box-shadow: 0.5em 0.5em 0 0 currentcolor; + } + 25% { + box-shadow: 0 0.5em 0 0px currentcolor; + } + 37% { + box-shadow: -0.5em 0.5em 0 0 currentcolor; + } + 50% { + box-shadow: -0.5em 0 0 0 currentcolor; + } + 62% { + box-shadow: -0.5em -0.5em 0 0 currentcolor; + } + 75% { + box-shadow: 0px -0.5em 0 0 currentcolor; + } + 87% { + box-shadow: 0.5em -0.5em 0 0 currentcolor; + } +} diff --git a/frontend/styles/components/Spinner.module.css b/frontend/styles/components/Spinner.module.css new file mode 100644 index 0000000..29a2155 --- /dev/null +++ b/frontend/styles/components/Spinner.module.css @@ -0,0 +1,19 @@ +.loader { + width: 48px; + height: 48px; + border: 5px solid #fff; + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +}