From 8bcfab22472a4f075f72afd26accfb253c64b9df Mon Sep 17 00:00:00 2001 From: james-gates-0212 Date: Mon, 29 Apr 2024 15:58:48 -0700 Subject: [PATCH 1/3] :art: fix: experience schema for migration --- src/database/models/experience.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/database/models/experience.ts b/src/database/models/experience.ts index fe553bc..69d9906 100644 --- a/src/database/models/experience.ts +++ b/src/database/models/experience.ts @@ -1,7 +1,7 @@ import { DataTypes } from 'sequelize'; export default function model(sequelize) { - const user = sequelize.define( + const experience = sequelize.define( 'experience', { id: { @@ -9,6 +9,10 @@ export default function model(sequelize) { autoIncrement: true, primaryKey: true, }, + profile: { + type: DataTypes.INTEGER, + allowNull: false, + }, since: { type: DataTypes.DATEONLY, allowNull: true, @@ -44,6 +48,9 @@ export default function model(sequelize) { }, { indexes: [ + { + fields: ['profile'], + }, { fields: ['since'], }, @@ -55,5 +62,15 @@ export default function model(sequelize) { }, ); - return user; + experience.associate = (models) => { + models.experience.belongsTo(models.profile, { + as: 'profile_fk', + constraints: true, + foreignKey: 'profile', + onDelete: 'NO ACTION', + onUpdate: 'NO ACTION', + }); + }; + + return experience; } From 6be637746bf70e1fe3be0ad1aacd31edc4789f11 Mon Sep 17 00:00:00 2001 From: james-gates-0212 Date: Tue, 30 Apr 2024 07:57:16 -0700 Subject: [PATCH 2/3] :building_construction: add: devMode flag & print error in console --- src/config/index.ts | 2 ++ src/database/repositories/sequelizeRepository.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/config/index.ts b/src/config/index.ts index fe727d2..2cdc97f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -21,6 +21,7 @@ type TConfig = { }; node: { env: string; + devMode: boolean; }; google: { verification: string; @@ -39,6 +40,7 @@ export function getConfig(): TConfig { }, node: { env: env.NODE_ENV || 'production', + devMode: env.NODE_ENV === 'development', }, google: { verification: env.GOOGLE_VERIFICATION || '', diff --git a/src/database/repositories/sequelizeRepository.ts b/src/database/repositories/sequelizeRepository.ts index f716dd4..b62e684 100644 --- a/src/database/repositories/sequelizeRepository.ts +++ b/src/database/repositories/sequelizeRepository.ts @@ -57,6 +57,9 @@ export default class SequelizeRepository { static handleUniqueFieldError(error, language, entityName) { if (!(error instanceof UniqueConstraintError)) { + if (getConfig().node.devMode) { + console.error(error); + } return; } From 1413c96920a7a21dce64cb13d08c67633e05f57e Mon Sep 17 00:00:00 2001 From: james-gates-0212 Date: Fri, 3 May 2024 10:31:09 -0400 Subject: [PATCH 3/3] :triangular_flag_on_post: feat: experience management --- src/app/admin/experience/page.tsx | 235 ++++++++++++++++ src/app/api/experience/[id]/route.ts | 69 +++++ src/app/api/experience/route.ts | 10 + src/components/Interfaces.ts | 24 ++ src/components/admin/experience/Modal.tsx | 260 ++++++++++++++++++ src/components/admin/menu.ts | 4 + src/components/admin/profile/Modal.tsx | 8 +- src/components/admin/user/Modal.tsx | 8 +- src/database/models/experience.ts | 22 ++ .../repositories/experienceRepository.ts | 125 +++++++++ src/i18n/en.ts | 20 ++ src/services/experienceService.ts | 101 +++++++ 12 files changed, 874 insertions(+), 12 deletions(-) create mode 100644 src/app/admin/experience/page.tsx create mode 100644 src/app/api/experience/[id]/route.ts create mode 100644 src/app/api/experience/route.ts create mode 100644 src/components/Interfaces.ts create mode 100644 src/components/admin/experience/Modal.tsx create mode 100644 src/database/repositories/experienceRepository.ts create mode 100644 src/services/experienceService.ts diff --git a/src/app/admin/experience/page.tsx b/src/app/admin/experience/page.tsx new file mode 100644 index 0000000..34d8d95 --- /dev/null +++ b/src/app/admin/experience/page.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { Button, Select, Spinner } from 'flowbite-react'; +import { DEFAULT_MOMENT_FORMAT } from '@/components/Commons'; +import { i18n } from '@/i18n'; +import { IProfile } from '@/components/Interfaces'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import BreadCrumb from '@/admin/common/BreadCrumb'; +import ConfirmModal from '@/admin/common/ConfirmModal'; +import ExperienceInfoModal from '@/components/admin/experience/Modal'; +import MyTable from '@/admin/common/MyTable'; +import moment from 'moment'; + +export default function Page() { + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [profiles, setProfiles] = useState>([]); + + const profileRef = useRef(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [modal, setModal] = useState(null); + + const handleCloseDeleteConfirm = useCallback(() => setDeleteConfirm(null), []); + const handleDeleteConfirm = useCallback(() => { + setLoading(true); + const delId = deleteConfirm; + setDeleteConfirm(null); + + fetch(`/api/experience/${delId}`, { method: 'DELETE' }) + .then(async (response) => { + const { rows } = await response.json(); + setData(rows); + }) + .catch((e) => { + console.error(e); + }) + .finally(() => setLoading(false)); + }, [deleteConfirm]); + + const handleRefreshTable = useCallback(() => { + setLoading(true); + fetch('/api/experience', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + profile: profileRef.current?.value, + }), + }) + .then(async (response) => { + const { rows } = await response.json(); + setData(rows); + }) + .catch((e) => { + console.error(e); + }) + .finally(() => setLoading(false)); + }, []); + + const handleRefreshProfiles = useCallback( + () => + fetch('/api/profile', { + method: 'POST', + }) + .then(async (response) => { + const { rows } = await response.json(); + setProfiles(rows); + }) + .catch((e) => { + console.error(e); + }) + .finally(() => setLoading(false)), + [], + ); + + const renderExperiencePeriod = useCallback((value) => { + if (!/\d{4}-\d{2}-\d{2}/.test(value)) { + return null; + } + const momentValue = moment(value); + const months = moment.monthsShort(); + return [months[momentValue.month()], momentValue.year()].join(' '); + }, []); + + useEffect(() => { + setLoading(true); + + handleRefreshProfiles(); + }, [handleRefreshProfiles]); + + return ( + <> + {useMemo( + () => ( +
+
+ +
+ + +
+
+ {loading ? ( +
+ +
+ ) : ( + ( + + { + evt.preventDefault(); + evt.stopPropagation(); + if (modal) { + return; + } + setModal(value); + }} + > + {i18n('common.edit')} + + { + evt.preventDefault(); + evt.stopPropagation(); + if (deleteConfirm) { + return; + } + setDeleteConfirm(value); + }} + > + {i18n('common.delete')} + + + ), + }, + ]} + rows={data} + hasCheckBox + hoverable + /> + )} +
+ ), + [loading, data, deleteConfirm, modal, profiles, handleRefreshTable, renderExperiencePeriod], + )} + {useMemo( + () => ( + setModal(null)} + profileId={profileRef.current?.value || 0} + recId={modal} + handleRefresh={handleRefreshTable} + /> + ), + [handleRefreshTable, modal], + )} + {useMemo( + () => ( + + ), + [handleCloseDeleteConfirm, handleDeleteConfirm, deleteConfirm], + )} + + ); +} diff --git a/src/app/api/experience/[id]/route.ts b/src/app/api/experience/[id]/route.ts new file mode 100644 index 0000000..164817f --- /dev/null +++ b/src/app/api/experience/[id]/route.ts @@ -0,0 +1,69 @@ +import ExperienceService from '@/services/experienceService'; +import { NextRequest } from 'next/server'; + +export async function POST(request: NextRequest, { params }: { params: { id: string } }) { + try { + const service: ExperienceService = new ExperienceService(); + await service.init(); + + const data = await request.json(); + + const { id } = params; + + const recId = parseInt(id) || 0; + + if (recId <= 0) { + await service.create(data); + } else { + await service.update(recId, data); + } + + return Response.json({}); + } catch (error) { + return Response.json(error); + } +} + +export async function GET(request: NextRequest, { params }: { params: { id: string } }) { + try { + const service: ExperienceService = new ExperienceService(); + await service.init(); + + const { id } = params; + + const recId = parseInt(id) || 0; + + let result = {}; + + if (recId > 0) { + result = await service.findById(recId); + } + + return Response.json(result); + } catch (error) { + return Response.json(error); + } +} + +export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) { + try { + const service: ExperienceService = new ExperienceService(); + await service.init(); + + const { id } = params; + + const recId = parseInt(id) || 0; + + let profile = 0; + + if (recId > 0) { + profile = await service.destroy(recId); + } + + const result = await service.getAll({ profile }); + + return Response.json(result); + } catch (error) { + return Response.json(error); + } +} diff --git a/src/app/api/experience/route.ts b/src/app/api/experience/route.ts new file mode 100644 index 0000000..890f5a2 --- /dev/null +++ b/src/app/api/experience/route.ts @@ -0,0 +1,10 @@ +import ExperienceService from '@/services/experienceService'; +import { NextRequest } from 'next/server'; + +export async function POST(request: NextRequest) { + const service: ExperienceService = new ExperienceService(); + await service.init(); + const data = await request.json(); + const { count, rows } = await service.getAll(data); + return Response.json({ count, rows }); +} diff --git a/src/components/Interfaces.ts b/src/components/Interfaces.ts new file mode 100644 index 0000000..1de1021 --- /dev/null +++ b/src/components/Interfaces.ts @@ -0,0 +1,24 @@ +export interface IProfile { + id?: number; + name?: string; + description?: string; +} + +export interface IExperience { + id?: number; + profile?: number; + since?: string; + until?: string; + position?: string; + company?: string; + link?: string; + linkedin?: string; + description?: string; + explanation?: string; +} + +export interface IUser { + id?: number; + key?: string; + value?: string; +} diff --git a/src/components/admin/experience/Modal.tsx b/src/components/admin/experience/Modal.tsx new file mode 100644 index 0000000..10d5aa1 --- /dev/null +++ b/src/components/admin/experience/Modal.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { Button, Label, Modal, Select, Spinner, TextInput, Textarea } from 'flowbite-react'; +import { i18n } from '@/i18n'; +import { IExperience } from '@/components/Interfaces'; +import { useEffect, useRef, useState } from 'react'; +import moment from 'moment'; + +export default function ExperienceInfoModal(props) { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [data, setData] = useState({}); + const { onClose, profileId, recId, handleRefresh } = props; + const sinceYearInputRef = useRef(null); + const [sinceMonth, setSinceMonth] = useState(0); + const sinceMonthSelectRef = useRef(null); + const untilYearInputRef = useRef(null); + const [untilMonth, setUntilMonth] = useState(0); + const untilMonthSelectRef = useRef(null); + const positionInputRef = useRef(null); + const companyInputRef = useRef(null); + const linkInputRef = useRef(null); + const linkedinInputRef = useRef(null); + const descriptionInputRef = useRef(null); + const explanationInputRef = useRef(null); + + const onSave = () => { + setSaving(true); + + fetch(`/api/experience/${recId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + profile: profileId, + since: `${sinceYearInputRef.current?.value}-${sinceMonthSelectRef.current?.value}`, + until: `${untilYearInputRef.current?.value}-${untilMonthSelectRef.current?.value}`, + position: positionInputRef.current?.value, + company: companyInputRef.current?.value, + link: linkInputRef.current?.value, + linkedin: linkedinInputRef.current?.value, + description: descriptionInputRef.current?.value, + explanation: explanationInputRef.current?.value, + }), + }) + .then(async (response) => { + setSaving(false); + onClose(); + handleRefresh && handleRefresh.call(null); + }) + .catch((e) => { + setSaving(false); + console.error(e); + }); + }; + + useEffect(() => { + if (recId <= 0) { + setData({}); + return; + } + + if (loading) { + return; + } + + setLoading(true); + + fetch(`/api/experience/${recId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(async (response) => { + setLoading(false); + const resp = await response.json(); + setData(resp); + resp.since && setSinceMonth(moment(resp.since).month() + 1); + resp.until && setUntilMonth(moment(resp.until).month() + 1); + }) + .catch((e) => { + setLoading(false); + console.error(e); + }); + }, [recId]); + + return ( + !saving && onClose()} initialFocus={sinceYearInputRef}> + + + {loading ? ( +
+ +
+ ) : ( +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+