init:employee app repository for running ci/cd pipeline
This commit is contained in:
commit
093d543717
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor & OS files
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker artifacts
|
||||||
|
*.tar
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Ignore local kubeconfig or secrets
|
||||||
|
kubeconfig.yaml
|
||||||
|
secrets.yaml
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- push
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DOCKER_DRIVER: overlay2
|
||||||
|
IMAGE_BACKEND: $CI_REGISTRY/$CI_PROJECT_PATH/backend
|
||||||
|
IMAGE_FRONTEND: $CI_REGISTRY/$CI_PROJECT_PATH/frontend
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
- echo "Logging in to GitLab Container Registry..."
|
||||||
|
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
|
||||||
|
|
||||||
|
# BUILD BACKEND
|
||||||
|
build-backend:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
|
||||||
|
TAG="prod-latest"
|
||||||
|
ENVIRONMENT="prod"
|
||||||
|
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
|
||||||
|
TAG="staging-latest"
|
||||||
|
ENVIRONMENT="stag"
|
||||||
|
else
|
||||||
|
TAG="dev-latest"
|
||||||
|
ENVIRONMENT="dev"
|
||||||
|
fi
|
||||||
|
- echo "Building backend image for $ENVIRONMENT"
|
||||||
|
- docker build -t $IMAGE_BACKEND:$TAG employee-be
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
- dev
|
||||||
|
|
||||||
|
# BUILD FRONTEND
|
||||||
|
build-frontend:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
|
||||||
|
TAG="prod-latest"
|
||||||
|
ENVIRONMENT="prod"
|
||||||
|
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
|
||||||
|
TAG="staging-latest"
|
||||||
|
ENVIRONMENT="stag"
|
||||||
|
else
|
||||||
|
TAG="dev-latest"
|
||||||
|
ENVIRONMENT="dev"
|
||||||
|
fi
|
||||||
|
- echo "Building frontend image for $ENVIRONMENT"
|
||||||
|
- docker build -t $IMAGE_FRONTEND:$TAG employee-fe
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
- dev
|
||||||
|
|
||||||
|
# PUSH IMAGES TO REGISTRY
|
||||||
|
push-images:
|
||||||
|
stage: push
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
|
||||||
|
TAG="prod-latest"
|
||||||
|
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
|
||||||
|
TAG="staging-latest"
|
||||||
|
else
|
||||||
|
TAG="dev-latest"
|
||||||
|
fi
|
||||||
|
- echo "Pushing images with tag $TAG..."
|
||||||
|
- docker push $IMAGE_BACKEND:$TAG
|
||||||
|
- docker push $IMAGE_FRONTEND:$TAG
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
- dev
|
||||||
|
|
||||||
|
# DEPLOY USING KUSTOMIZE
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
stage: deploy
|
||||||
|
image:
|
||||||
|
name: bitnami/kubectl:latest
|
||||||
|
entrypoint: [""]
|
||||||
|
script:
|
||||||
|
- echo "$KUBECONFIG_DATA" | base64 -d > kubeconfig.yaml
|
||||||
|
- export KUBECONFIG=$(pwd)/kubeconfig.yaml
|
||||||
|
- |
|
||||||
|
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
|
||||||
|
ENVIRONMENT="prod"
|
||||||
|
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
|
||||||
|
ENVIRONMENT="stag"
|
||||||
|
else
|
||||||
|
ENVIRONMENT="dev"
|
||||||
|
fi
|
||||||
|
- echo "Deploying to $ENVIRONMENT environment..."
|
||||||
|
- kubectl apply -k employee-manifest/overlays/$ENVIRONMENT
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
- dev
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:18
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const db = require('./src/models');
|
||||||
|
const User = db.User;
|
||||||
|
|
||||||
|
async function hashExistingPasswords() {
|
||||||
|
const users = await User.findAll();
|
||||||
|
for (const user of users) {
|
||||||
|
// hash hanya jika password belum di-hash
|
||||||
|
if (!user.password.startsWith('$2b$')) {
|
||||||
|
const hash = await bcrypt.hash(user.password, 10);
|
||||||
|
user.password = hash;
|
||||||
|
await user.save();
|
||||||
|
console.log(`Password user ${user.email} berhasil di-hash`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Selesai hash semua password lama');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hashExistingPasswords();
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "employee-be",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-validator": "^7.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.1",
|
||||||
|
"mysql2": "^3.15.1",
|
||||||
|
"sequelize": "^6.37.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const sequelize = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASS, {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
dialect: 'mysql',
|
||||||
|
port: process.env.DB_PORT || 3306,
|
||||||
|
logging: false,
|
||||||
|
define: { timestamps: true, underscored: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = sequelize;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const User = require('../models/user.model');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
exports.register = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, email, password } = req.body;
|
||||||
|
const exists = await User.findOne({ where: { email }});
|
||||||
|
if (exists) return res.status(400).json({ msg: 'Email already registered' });
|
||||||
|
const salt = await bcrypt.genSalt(parseInt(process.env.BCRYPT_SALT_ROUNDS || 10));
|
||||||
|
const hash = await bcrypt.hash(password, salt);
|
||||||
|
const user = await User.create({ name, email, password: hash });
|
||||||
|
return res.status(201).json({ id: user.id, name: user.name, email: user.email });
|
||||||
|
} catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.login = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
const user = await User.findOne({ where: { email }});
|
||||||
|
if (!user) return res.status(401).json({ msg: 'Invalid credentials' });
|
||||||
|
const match = await bcrypt.compare(password, user.password);
|
||||||
|
if (!match) return res.status(401).json({ msg: 'Invalid credentials' });
|
||||||
|
const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '7d' });
|
||||||
|
return res.json({ token, user: { id: user.id, name: user.name, email: user.email } });
|
||||||
|
} catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); }
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
const Employee = require('../models/employee.model');
|
||||||
|
|
||||||
|
exports.list = async (req, res) => {
|
||||||
|
const employees = await Employee.findAll({ order: [['id', 'DESC']] });
|
||||||
|
res.json(employees);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.get = async (req, res) => {
|
||||||
|
const emp = await Employee.findByPk(req.params.id);
|
||||||
|
if (!emp) return res.status(404).json({ msg: 'Employee not found' });
|
||||||
|
res.json(emp);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
const data = req.body;
|
||||||
|
try {
|
||||||
|
const created = await Employee.create(data);
|
||||||
|
res.status(201).json(created);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
const emp = await Employee.findByPk(req.params.id);
|
||||||
|
if (!emp) return res.status(404).json({ msg: 'Employee not found' });
|
||||||
|
await emp.update(req.body);
|
||||||
|
res.json(emp);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
const emp = await Employee.findByPk(req.params.id);
|
||||||
|
if (!emp) return res.status(404).json({ msg: 'Employee not found' });
|
||||||
|
await emp.destroy();
|
||||||
|
res.json({ msg: 'Deleted' });
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = (req, res, next) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
if (!authHeader) return res.status(401).json({ msg: 'No token provided' });
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ msg: 'Invalid/Expired token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const Employee = sequelize.define('Employee', {
|
||||||
|
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||||
|
name: { type: DataTypes.STRING, allowNull: false },
|
||||||
|
email: { type: DataTypes.STRING, allowNull: false, unique: true },
|
||||||
|
phone: { type: DataTypes.STRING, allowNull: true },
|
||||||
|
position: { type: DataTypes.STRING, allowNull: true },
|
||||||
|
department: { type: DataTypes.STRING, allowNull: true },
|
||||||
|
salary: { type: DataTypes.DECIMAL(10,2), allowNull: true },
|
||||||
|
hired_date: { type: DataTypes.DATEONLY, allowNull: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Employee;
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
const User = require('./user.model');
|
||||||
|
const Employee = require('./employee.model');
|
||||||
|
|
||||||
|
const db = { sequelize, User, Employee };
|
||||||
|
|
||||||
|
// associations if needed
|
||||||
|
// e.g., User.hasMany(Employee);
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const User = sequelize.define('User', {
|
||||||
|
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||||
|
name: { type: DataTypes.STRING, allowNull: false },
|
||||||
|
email: { type: DataTypes.STRING, allowNull: false, unique: true },
|
||||||
|
password: { type: DataTypes.STRING, allowNull: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = User;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const authCtrl = require('../controllers/auth.controller');
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/register', authCtrl.register);
|
||||||
|
router.post('/login', authCtrl.login);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const empCtrl = require('../controllers/employee.controller');
|
||||||
|
const auth = require('../middlewares/auth.middleware');
|
||||||
|
|
||||||
|
router.get('/', auth, empCtrl.list);
|
||||||
|
router.get('/:id', auth, empCtrl.get);
|
||||||
|
router.post('/', auth, empCtrl.create);
|
||||||
|
router.put('/:id', auth, empCtrl.update);
|
||||||
|
router.delete('/:id', auth, empCtrl.delete);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const db = require('./models');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => res.send('OK'));
|
||||||
|
|
||||||
|
// routes
|
||||||
|
app.use('/api/auth', require('./routes/auth.routes'));
|
||||||
|
app.use('/api/employees', require('./routes/employee.routes'));
|
||||||
|
|
||||||
|
// start
|
||||||
|
const PORT = process.env.PORT || 4000;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await db.sequelize.authenticate();
|
||||||
|
console.log('DB connected');
|
||||||
|
await db.sequelize.sync({ alter: true });
|
||||||
|
app.listen(PORT, () => console.log(`Server running on ${PORT}`));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Unable to start server', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Stage build
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Running stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
RUN sed -i 's/listen\s\+80;/listen 8080;/' /etc/nginx/conf.d/default.conf
|
||||||
|
RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx && \
|
||||||
|
chown -R 1000:1000 /var/cache/nginx /var/run /var/log/nginx
|
||||||
|
|
||||||
|
USER 1000
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EMS APP</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "employee-fe",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"animate.css": "^4.1.1",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"bootstrap-icons": "^1.13.1",
|
||||||
|
"sweetalert2": "^11.23.0",
|
||||||
|
"vue": "^3.5.21",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<div v-if="authToken">
|
||||||
|
<div class="d-flex">
|
||||||
|
<nav class="sidebar p-3 vh-100 text-white">
|
||||||
|
<h4>EMS</h4>
|
||||||
|
<ul class="list-unstyled mt-4">
|
||||||
|
<li><router-link to="/dashboard" class="text-white mb-2 d-block">Dashboard</router-link></li>
|
||||||
|
<li><router-link to="/employees" class="text-white mb-2 d-block">Employees</router-link></li>
|
||||||
|
<li><a href="#" @click="logout" class="text-white">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="content flex-fill p-4">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const authToken = inject('authToken');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function logout(){
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
authToken.value = null;
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar { width: 250px; background-color: #1a1a1a; }
|
||||||
|
.sidebar a { text-decoration: none; }
|
||||||
|
.sidebar a:hover { text-decoration: underline; }
|
||||||
|
.content { background-color: #121212; min-height: 100vh; color: #fff; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { authToken } from '../main.js';
|
||||||
|
|
||||||
|
const API = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
API.interceptors.request.use(config => {
|
||||||
|
const token = authToken.value;
|
||||||
|
if(token) config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default API;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,50 @@
|
||||||
|
<template>
|
||||||
|
<div class="card mb-3 p-3 bg-dark text-white">
|
||||||
|
<h5>{{ employee?.id ? 'Edit Employee' : 'Add Employee' }}</h5>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="emp.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="emp.email" type="email" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Position</label>
|
||||||
|
<input v-model="emp.position" type="text" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Salary</label>
|
||||||
|
<input v-model="emp.salary" type="number" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary me-2">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import API from '../api/api.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['employee'],
|
||||||
|
setup(props, { emit }){
|
||||||
|
const emp = ref({ name:'', email:'', position:'', salary:0, ...props.employee });
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if(emp.value.id){
|
||||||
|
await API.put(`/employees/${emp.value.id}`, emp.value);
|
||||||
|
} else {
|
||||||
|
await API.post('/employees', emp.value);
|
||||||
|
}
|
||||||
|
emit('saved');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { emp, handleSubmit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nama</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Posisi</th>
|
||||||
|
<th>Gaji</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="emp in employees" :key="emp.id">
|
||||||
|
<td>{{ emp.id }}</td>
|
||||||
|
<td>{{ emp.name }}</td>
|
||||||
|
<td>{{ emp.email }}</td>
|
||||||
|
<td>{{ emp.position }}</td>
|
||||||
|
<td>{{ emp.salary }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="$emit('edit', emp)" class="btn-primary">Edit</button>
|
||||||
|
<button @click="$emit('delete', emp.id)" class="btn-secondary">Hapus</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({ employees: Array })
|
||||||
|
const emit = defineEmits(['edit', 'delete'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; border: none; padding: 0.3rem 0.7rem; cursor: pointer; margin-right: 0.3rem; }
|
||||||
|
.btn-secondary { background: #aaa; color: #fff; border: none; padding: 0.3rem 0.7rem; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { createApp, ref, provide } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
|
import 'bootstrap';
|
||||||
|
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||||
|
import 'animate.css';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
export const authToken = ref(localStorage.getItem('token'));
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.provide('authToken', authToken);
|
||||||
|
app.use(router).mount('#app');
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import Login from '../views/login.vue';
|
||||||
|
import Dashboard from '../views/dashboard.vue';
|
||||||
|
import Employees from '../views/employees.vue';
|
||||||
|
import Register from '../views/register.vue';
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', redirect: '/login' },
|
||||||
|
{ path: '/login', component: Login },
|
||||||
|
{ path: '/register', component: Register },
|
||||||
|
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
|
||||||
|
{ path: '/employees', component: Employees, meta: { requiresAuth: true } }
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({ history: createWebHistory(), routes });
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (to.meta.requiresAuth && !token) return next('/login');
|
||||||
|
if ((to.path === '/login' || to.path === '/register') && token) return next('/dashboard');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import API from '../api/api.js';
|
||||||
|
|
||||||
|
export async function login(email, password){
|
||||||
|
return (await API.post('/auth/login', { email, password })).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(data){
|
||||||
|
return (await API.post('/auth/register', data)).data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
:root {
|
||||||
|
--primary: #0d47a1;
|
||||||
|
--accent: #0d9488;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--bg-dark: #121212;
|
||||||
|
--text-light: #ffffff;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
color: var(--text-light);
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.table-dark {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
input.form-control {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: var(--text-light);
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-4 text-white" style="background-color:#121212; min-height:100vh;">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<p>Selamat datang di Employee Management System!</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<template>
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="content flex-grow-1 p-4">
|
||||||
|
<h3>Employees</h3>
|
||||||
|
<button class="btn btn-primary mb-3" @click="create">+ Add Employee</button>
|
||||||
|
<employee-form v-if="showForm" :employee="editing" @saved="onSaved" @cancel="showForm=false" />
|
||||||
|
<table class="table table-striped table-dark">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Position</th>
|
||||||
|
<th>Salary</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="emp in employees" :key="emp.id">
|
||||||
|
<td>{{ emp.name }}</td>
|
||||||
|
<td>{{ emp.email }}</td>
|
||||||
|
<td>{{ emp.position }}</td>
|
||||||
|
<td>{{ emp.salary }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-warning me-1" @click="edit(emp)">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-danger" @click="deleteEmp(emp.id)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import EmployeeForm from '../components/employeeForm.vue';
|
||||||
|
import API from '../api/api.js';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
import { authToken } from '../main.js';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { EmployeeForm },
|
||||||
|
data(){ return { employees: [], loading:true, showForm:false, editing:null }; },
|
||||||
|
async mounted(){ await this.load(); },
|
||||||
|
methods:{
|
||||||
|
async load(){
|
||||||
|
this.loading=true;
|
||||||
|
try { this.employees = (await API.get('/employees')).data; }
|
||||||
|
finally { this.loading=false; }
|
||||||
|
},
|
||||||
|
create(){ this.editing=null; this.showForm=true; },
|
||||||
|
edit(emp){ this.editing=emp; this.showForm=true; },
|
||||||
|
async deleteEmp(id){
|
||||||
|
const confirmed = await Swal.fire({ title:'Confirm delete?', showCancelButton:true, confirmButtonColor:'#0d47a1' });
|
||||||
|
if (!confirmed.isConfirmed) return;
|
||||||
|
await API.delete(`/employees/${id}`);
|
||||||
|
Swal.fire({title:'Deleted', icon:'success', confirmButtonColor:'#0d47a1'});
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
onSaved(){ this.showForm=false; this.load(); Swal.fire({title:'Saved', icon:'success', confirmButtonColor:'#0d47a1'}); },
|
||||||
|
logout(){ localStorage.removeItem('token'); authToken.value=null; this.$router.push('/login'); },
|
||||||
|
goDashboard(){ this.$router.push('/dashboard'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar{ width: 250px; }
|
||||||
|
.content{ background-color: #121212; min-height:100vh; color:white; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<template>
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="card mx-auto shadow" style="max-width:400px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="text-center mb-4">Sign In</h4>
|
||||||
|
<form @submit.prevent="doLogin">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="email" type="email" class="form-control" placeholder="Email" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Password</label>
|
||||||
|
<input v-model="password" type="password" class="form-control" placeholder="Password" required />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100" :disabled="loading">
|
||||||
|
<span v-if="!loading">Login</span>
|
||||||
|
<span v-else class="spinner-border spinner-border-sm"></span>
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-center">
|
||||||
|
Belum punya akun? <router-link to="/register">Register</router-link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { login } from '../services/auth.services.js';
|
||||||
|
import { authToken } from '../main.js';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const doLogin = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await login(email.value, password.value);
|
||||||
|
localStorage.setItem('token', res.token);
|
||||||
|
authToken.value = res.token;
|
||||||
|
Swal.fire({ title:'Logged in', icon:'success', confirmButtonColor:'#0d47a1' });
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err){
|
||||||
|
Swal.fire({
|
||||||
|
title:'Login failed',
|
||||||
|
text: err.response?.data?.msg || err.message,
|
||||||
|
icon:'error',
|
||||||
|
confirmButtonColor:'#0d47a1'
|
||||||
|
});
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<template>
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="card mx-auto shadow" style="max-width: 400px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title mb-4 text-center">Register</h4>
|
||||||
|
<form @submit.prevent="doRegister">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="name" type="text" class="form-control" placeholder="Full Name" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="email" type="email" class="form-control" placeholder="Email" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Password</label>
|
||||||
|
<input v-model="password" type="password" class="form-control" placeholder="Password" required />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100" :disabled="loading">
|
||||||
|
<span v-if="!loading">Register</span>
|
||||||
|
<span v-else class="spinner-border spinner-border-sm"></span>
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-center">
|
||||||
|
Sudah punya akun? <router-link to="/login">Login</router-link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { register } from '../services/auth.services.js';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { authToken } from '../main.js';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const name = ref('');
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const doRegister = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await register({ name: name.value, email: email.value, password: password.value });
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Account created!',
|
||||||
|
icon: 'success',
|
||||||
|
confirmButtonColor: '#0d47a1'
|
||||||
|
});
|
||||||
|
router.push('/login');
|
||||||
|
} catch (err) {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Register failed',
|
||||||
|
text: err.response?.data?.msg || err.message,
|
||||||
|
icon: 'error',
|
||||||
|
confirmButtonColor: '#0d47a1'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { name, email, password, loading, doRegister };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue