Compare commits
117 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
4b146ff5e9 | |
|
|
cf71247e93 | |
|
|
1fc065a59b | |
|
|
b44278e1a0 | |
|
|
7718e95ee5 | |
|
|
e4dadea8da | |
|
|
9eb1a3ff1e | |
|
|
c2ef59c621 | |
|
|
85a178e825 | |
|
|
b0d296e007 | |
|
|
b4403c2cd5 | |
|
|
e3692467e3 | |
|
|
c03c89d613 | |
|
|
05e706e150 | |
|
|
d7128000be | |
|
|
2306e54515 | |
|
|
d71ac936fa | |
|
|
9ab39832b5 | |
|
|
68eb853987 | |
|
|
94840a93dd | |
|
|
9172534f31 | |
|
|
394150a2ee | |
|
|
882f4acad8 | |
|
|
6c223f01c9 | |
|
|
d4d6ff5344 | |
|
|
1cc863d3c9 | |
|
|
0e5f059682 | |
|
|
77f91eb16f | |
|
|
217781cfe8 | |
|
|
0421a20b4a | |
|
|
586531bdbc | |
|
|
312007655f | |
|
|
7020499fec | |
|
|
c0a89d3f69 | |
|
|
3efcf99151 | |
|
|
adfd911f7b | |
|
|
3177ac630d | |
|
|
bebb4fb980 | |
|
|
203a8d692c | |
|
|
de44bdac72 | |
|
|
9c8499ff2f | |
|
|
e4eb851bc1 | |
|
|
f5f94f016b | |
|
|
ec566bb14a | |
|
|
2c4e158d67 | |
|
|
52e45f8328 | |
|
|
93704a70f6 | |
|
|
9eeb14ffcd | |
|
|
df2b35a4e4 | |
|
|
ed0bcb7107 | |
|
|
70193501d3 | |
|
|
475776b214 | |
|
|
0eea767e5e | |
|
|
17dcdec1c6 | |
|
|
4fb0720e7d | |
|
|
3ef615a160 | |
|
|
b55f9469ad | |
|
|
d23933c517 | |
|
|
a0c95b05bc | |
|
|
92e8c15023 | |
|
|
35eb073f46 | |
|
|
94eedff8af | |
|
|
ac3ddf4b2b | |
|
|
a55e6ccba4 | |
|
|
f1c66c6621 | |
|
|
35fa90705b | |
|
|
bd4a3656e7 | |
|
|
345822885f | |
|
|
d94db40a80 | |
|
|
0ae8f6185a | |
|
|
882e8349c9 | |
|
|
d641e9432d | |
|
|
0328183852 | |
|
|
39014708d7 | |
|
|
0b7d844753 | |
|
|
9fc9b964c2 | |
|
|
880ef9eae5 | |
|
|
ff32a54aac | |
|
|
6a122f3d51 | |
|
|
3a34520152 | |
|
|
e7696925e7 | |
|
|
558ea81fc2 | |
|
|
4ab4164f65 | |
|
|
45a3040614 | |
|
|
2212359c96 | |
|
|
deb66976e9 | |
|
|
1d71f8f369 | |
|
|
e324beca50 | |
|
|
df92e68a0a | |
|
|
f0e6b704cd | |
|
|
c0f0986a5f | |
|
|
c13a4ff356 | |
|
|
51b47beba9 | |
|
|
8bf3343ba3 | |
|
|
be819cd1bb | |
|
|
d6481a0c27 | |
|
|
44d790abab | |
|
|
f7e77f895c | |
|
|
fc9820217b | |
|
|
b56a6e11c4 | |
|
|
c2213e0969 | |
|
|
f499cbfa7b | |
|
|
f3eb099e18 | |
|
|
8d2961c50d | |
|
|
b01ad415f1 | |
|
|
d0e8ded221 | |
|
|
11398c6869 | |
|
|
aa00c75edf | |
|
|
b448e2a253 | |
|
|
d42a26ad8e | |
|
|
bb8b654e93 | |
|
|
70cd30af95 | |
|
|
fbc5de3a59 | |
|
|
fbf84bb7aa | |
|
|
3f190f333b | |
|
|
94a6ebdac7 | |
|
|
ea74c00dde |
|
|
@ -11,7 +11,9 @@ node_modules
|
|||
dist
|
||||
dist-react
|
||||
dist-electron
|
||||
release
|
||||
*.local
|
||||
release
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
@ -28,3 +30,4 @@ nextSteps.txt
|
|||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
resources
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Force use of prebuilt binaries
|
||||
prefer_binary=true
|
||||
build_from_source=false
|
||||
|
||||
# Skip building from source
|
||||
ignore_scripts=false
|
||||
|
||||
# Electron configuration
|
||||
runtime=electron
|
||||
target=32.1.2
|
||||
disturl=https://electronjs.org/headers
|
||||
|
||||
# Don't fail on optional dependencies
|
||||
optional=true
|
||||
|
||||
# Use production registry
|
||||
registry=https://registry.npmjs.org/
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import figlet from "figlet";
|
||||
import chalk from "chalk";
|
||||
|
||||
figlet("Jai Pandey DocVA", (err, data) => {
|
||||
if (err) return console.log("Error generating banner");
|
||||
console.log(chalk.cyan(data));
|
||||
console.log(chalk.green("\n🚀 Development Mode Starting...\n"));
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Windows
|
||||
GOOS=windows GOARCH=amd64 go build -o ./bin/win/wireguard-go.exe ./wireguard-go
|
||||
|
||||
# Linux
|
||||
GOOS=linux GOARCH=amd64 go build -o ./bin/linux/wireguard-go ./wireguard-go
|
||||
|
||||
# macOS Intel
|
||||
GOOS=darwin GOARCH=amd64 go build -o ./bin/mac/wireguard-go ./wireguard-go
|
||||
|
||||
# macOS ARM
|
||||
GOOS=darwin GOARCH=arm64 go build -o ./bin/mac/wireguard-go-arm ./wireguard-go
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 435 KiB |
|
|
@ -0,0 +1,132 @@
|
|||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var test_1 = require("@playwright/test");
|
||||
var electronApp;
|
||||
var mainPage;
|
||||
function waitForPreloadScript() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var _this = this;
|
||||
return __generator(this, function (_a) {
|
||||
return [2 /*return*/, new Promise(function (resolve) {
|
||||
var interval = setInterval(function () { return __awaiter(_this, void 0, void 0, function () {
|
||||
var electronBridge;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, mainPage.evaluate(function () {
|
||||
return window.electron;
|
||||
})];
|
||||
case 1:
|
||||
electronBridge = _a.sent();
|
||||
if (electronBridge) {
|
||||
clearInterval(interval);
|
||||
resolve(true);
|
||||
}
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); }, 100);
|
||||
})];
|
||||
});
|
||||
});
|
||||
}
|
||||
test_1.test.beforeEach(function () { return __awaiter(void 0, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, test_1._electron.launch({
|
||||
args: ['.'],
|
||||
env: { NODE_ENV: 'development' },
|
||||
})];
|
||||
case 1:
|
||||
electronApp = _a.sent();
|
||||
return [4 /*yield*/, electronApp.firstWindow()];
|
||||
case 2:
|
||||
mainPage = _a.sent();
|
||||
return [4 /*yield*/, waitForPreloadScript()];
|
||||
case 3:
|
||||
_a.sent();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); });
|
||||
test_1.test.afterEach(function () { return __awaiter(void 0, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, electronApp.close()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); });
|
||||
(0, test_1.test)('custom frame should minimize the mainWindow', function () { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var isMinimized;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, mainPage.click('#minimize')];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, electronApp.evaluate(function (electron) {
|
||||
return electron.BrowserWindow.getAllWindows()[0].isMinimized();
|
||||
})];
|
||||
case 2:
|
||||
isMinimized = _a.sent();
|
||||
(0, test_1.expect)(isMinimized).toBeTruthy();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); });
|
||||
(0, test_1.test)('should create a custom menu', function () { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var menu;
|
||||
var _a, _b;
|
||||
return __generator(this, function (_c) {
|
||||
switch (_c.label) {
|
||||
case 0: return [4 /*yield*/, electronApp.evaluate(function (electron) {
|
||||
return electron.Menu.getApplicationMenu();
|
||||
})];
|
||||
case 1:
|
||||
menu = _c.sent();
|
||||
(0, test_1.expect)(menu).not.toBeNull();
|
||||
(0, test_1.expect)(menu === null || menu === void 0 ? void 0 : menu.items).toHaveLength(2);
|
||||
(0, test_1.expect)((_a = menu === null || menu === void 0 ? void 0 : menu.items[0].submenu) === null || _a === void 0 ? void 0 : _a.items).toHaveLength(2);
|
||||
(0, test_1.expect)((_b = menu === null || menu === void 0 ? void 0 : menu.items[1].submenu) === null || _b === void 0 ? void 0 : _b.items).toHaveLength(3);
|
||||
(0, test_1.expect)(menu === null || menu === void 0 ? void 0 : menu.items[1].label).toBe('View');
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); });
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"appId": "com.n-ziermann.electron-course",
|
||||
"files": ["dist-electron", "dist-react"],
|
||||
"extraResources": ["dist-electron/preload.cjs", "src/assets/**"],
|
||||
"icon": "./desktopIcon.png",
|
||||
"mac": {
|
||||
"target": "dmg"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"category": "Utility"
|
||||
},
|
||||
"win": {
|
||||
"target": ["portable", "msi"]
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,11 @@
|
|||
<meta charset="UTF-8" />
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="" /> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VA Tracker</title>
|
||||
<meta
|
||||
<title>DocVa</title>
|
||||
<!-- <meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self';"
|
||||
/>
|
||||
/> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
111
package.json
111
package.json
|
|
@ -1,50 +1,141 @@
|
|||
{
|
||||
"name": "va-tracker",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.1.1",
|
||||
"type": "module",
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all --parallel dev:react dev:electron",
|
||||
"banner": "node banner.js",
|
||||
"dev": "npm-run-all banner --parallel transpile:electron dev:react dev:electron",
|
||||
"dev:react": "vite",
|
||||
"dev:electron": "npm run transpile:electron && cross-env NODE_ENV=development electron --inspect=5858 .",
|
||||
"dev:electron": "npm run build && cross-env NODE_ENV=development electron --inspect=5858 .",
|
||||
"prod:electron": "npm run build && cross-env NODE_ENV=production electron .",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"transpile:electron": "tsc --project src/electron/tsconfig.json",
|
||||
"dist:mac": "npm run transpile:electron && npm run build && electron-builder --mac --arm64",
|
||||
"dist:mac": "npm run transpile:electron && npm run build && electron-builder --mac --universal",
|
||||
"dist:win": "npm run transpile:electron && npm run build && electron-builder --win --x64",
|
||||
"dist:linux": "npm run transpile:electron && npm run build && electron-builder --linux --x64",
|
||||
"test:e2e": "playwright test",
|
||||
"test:unit": "vitest src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.10.1",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"axios": "^1.13.2",
|
||||
"chalk": "^5.6.2",
|
||||
"electron-store": "^11.0.2",
|
||||
"get-windows": "^9.2.3",
|
||||
"figlet": "^1.9.4",
|
||||
"https": "^1.0.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"luxon": "^3.7.2",
|
||||
"os-utils": "^0.0.14",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-quill-new": "^3.6.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"recharts": "^2.12.7",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"active-win": "^7.7.2",
|
||||
"get-windows": "^9.2.3",
|
||||
"sharp": "^0.34.5",
|
||||
"windowmanager": "^0.14.8"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.vatracker.app",
|
||||
"productName": "VA Tracker",
|
||||
"npmRebuild": false,
|
||||
"buildDependenciesFromSource": false,
|
||||
"nodeGypRebuild": false,
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"icon": "build/icon.ico",
|
||||
"files": [
|
||||
"dist-electron/**/*",
|
||||
"dist-react/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist-electron",
|
||||
"to": "dist-electron"
|
||||
},
|
||||
{
|
||||
"from": "src/assets",
|
||||
"to": "assets"
|
||||
},
|
||||
{
|
||||
"from": "resources/wireguard-install",
|
||||
"to": "wireguard-install"
|
||||
},
|
||||
{
|
||||
"from": "resources/wireguard",
|
||||
"to": "wireguard"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": "universal"
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.icns",
|
||||
"extendInfo": {
|
||||
"LSUIElement": false,
|
||||
"LSBackgroundOnly": false
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"requestedExecutionLevel": "requireAdministrator",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"signAndEditExecutable": true,
|
||||
"signingHashAlgorithms": [],
|
||||
"verifyUpdateCodeSignature": false
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": true,
|
||||
"perMachine": false,
|
||||
"createDesktopShortcut": "always",
|
||||
"createStartMenuShortcut": true,
|
||||
"runAfterFinish": true,
|
||||
"installerIcon": "build/icon.ico",
|
||||
"uninstallerIcon": "build/icon.ico",
|
||||
"installerHeaderIcon": "build/icon.ico",
|
||||
"artifactName": "${productName}-Setup-${version}.${ext}"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/os-utils": "^0.0.4",
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"daisyui": "^5.4.3",
|
||||
"electron": "^32.1.2",
|
||||
"electron": "^39.2.5",
|
||||
"electron-builder": "^25.0.5",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rcedit": "^5.0.2",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.7",
|
||||
"vitest": "^2.1.1"
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use strict";
|
||||
var __assign = (this && this.__assign) || function () {
|
||||
__assign = Object.assign || function(t) {
|
||||
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
||||
s = arguments[i];
|
||||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
||||
t[p] = s[p];
|
||||
}
|
||||
return t;
|
||||
};
|
||||
return __assign.apply(this, arguments);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var test_1 = require("@playwright/test");
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
exports.default = (0, test_1.defineConfig)({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: __assign({}, test_1.devices['Desktop Chrome']),
|
||||
},
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run dev:react',
|
||||
url: 'http://localhost:5123',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
|
@ -0,0 +1,10 @@
|
|||
[Interface]
|
||||
PrivateKey = CEzCd25ufxbOjvRhMqLfkcdP/OziG5i6OkXCfMZrI1I=
|
||||
Address = 10.0.0.2/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = jG+7H5thEiNjxY1XqI1R9oyMAMTIegLwE3tZkS1Rj2U=
|
||||
Endpoint = 35.95.209.161:51820
|
||||
AllowedIPs = 10.0.0.0/32
|
||||
PersistentKeepalive = 25
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
[Interface]
|
||||
PrivateKey = gPqf7ScXv2xtLkZz5a8qa7RNJ7uU5TQUo/rTfcprMWE=
|
||||
Address = 10.0.0.172/32
|
||||
PrivateKey = wHjS7rJYnAGhlvj+XYLefAzki1uRM2xz95IxRML2q24=
|
||||
Address = 10.0.0.169/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = jG+7H5thEiNjxY1XqI1R9oyMAMTIegLwE3tZkS1Rj2U=
|
||||
Endpoint = 35.95.209.161:51820
|
||||
AllowedIPs = 10.0.0.0/24
|
||||
PersistentKeepalive = 25
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 435 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -0,0 +1,325 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
/* Circular spinner */
|
||||
.loader-ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 6px solid #e0e0e0;
|
||||
border-top-color: #6a5acd;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Logo in center */
|
||||
.loader-logo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loader-logo svg {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
margin-left: 12px;
|
||||
margin-top: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="loader-container">
|
||||
<div class="loader-ring"></div>
|
||||
|
||||
<!-- Place your logo here -->
|
||||
<div class="loader-logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100"
|
||||
zoomAndPan="magnify" viewBox="0 0 368.25 242.999999" height="100" preserveAspectRatio="xMidYMid meet"
|
||||
version="1.0">
|
||||
<defs>
|
||||
<clipPath id="17e5ee130b">
|
||||
<path d="M 137.171875 1.269531 L 231 1.269531 L 231 99 L 137.171875 99 Z M 137.171875 1.269531 "
|
||||
clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="0262fc8075">
|
||||
<path d="M 284 124 L 364.921875 124 L 364.921875 206 L 284 206 Z M 284 124 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="0914a8505c">
|
||||
<path d="M 3 231 L 14 231 L 14 241.671875 L 3 241.671875 Z M 3 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="da83339c1d">
|
||||
<path d="M 17 231 L 20 231 L 20 241.671875 L 17 241.671875 Z M 17 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="97dbddcd78">
|
||||
<path d="M 25 231 L 34 231 L 34 241.671875 L 25 241.671875 Z M 25 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="3223358027">
|
||||
<path d="M 36 231 L 46 231 L 46 241.671875 L 36 241.671875 Z M 36 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="f960afe448">
|
||||
<path d="M 49 231 L 59 231 L 59 241.671875 L 49 241.671875 Z M 49 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="04b92ea6b9">
|
||||
<path d="M 61 231 L 72 231 L 72 241.671875 L 61 241.671875 Z M 61 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="7b4adb9303">
|
||||
<path d="M 75 231 L 84 231 L 84 241.671875 L 75 241.671875 Z M 75 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="53357fd403">
|
||||
<path d="M 93 231 L 104 231 L 104 241.671875 L 93 241.671875 Z M 93 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="bcd03dbca6">
|
||||
<path d="M 106 231 L 117 231 L 117 241.671875 L 106 241.671875 Z M 106 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="ff8639fe11">
|
||||
<path d="M 120 231 L 130 231 L 130 241.671875 L 120 241.671875 Z M 120 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="3990de54cb">
|
||||
<path d="M 134 231 L 137 231 L 137 241.671875 L 134 241.671875 Z M 134 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="045cbd7804">
|
||||
<path d="M 141 231 L 151 231 L 151 241.671875 L 141 241.671875 Z M 141 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="7de9a85e20">
|
||||
<path d="M 154 231 L 163 231 L 163 241.671875 L 154 241.671875 Z M 154 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="e68f5b3fba">
|
||||
<path d="M 165 231 L 176 231 L 176 241.671875 L 165 241.671875 Z M 165 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="65464ab719">
|
||||
<path d="M 179 231 L 189 231 L 189 241.671875 L 179 241.671875 Z M 179 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="b82da71650">
|
||||
<path d="M 192 231 L 202 231 L 202 241.671875 L 192 241.671875 Z M 192 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="fe4eaf6826">
|
||||
<path d="M 204 231 L 215 231 L 215 241.671875 L 204 241.671875 Z M 204 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="79d174fbbf">
|
||||
<path d="M 225 231 L 234 231 L 234 241.671875 L 225 241.671875 Z M 225 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="b0be87ffda">
|
||||
<path d="M 236 230 L 249 230 L 249 241.671875 L 236 241.671875 Z M 236 230 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="444dbef42a">
|
||||
<path d="M 252 231 L 262 231 L 262 241.671875 L 252 241.671875 Z M 252 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="314903e6e8">
|
||||
<path d="M 272 230 L 283 230 L 283 241.671875 L 272 241.671875 Z M 272 230 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="2fd70bc44d">
|
||||
<path d="M 286 230 L 299 230 L 299 241.671875 L 286 241.671875 Z M 286 230 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="032e9be536">
|
||||
<path d="M 302 230 L 312 230 L 312 241.671875 L 302 241.671875 Z M 302 230 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="77ab9ec736">
|
||||
<path d="M 314 231 L 324 231 L 324 241.671875 L 314 241.671875 Z M 314 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="bf7e46828b">
|
||||
<path d="M 326 230 L 339 230 L 339 241.671875 L 326 241.671875 Z M 326 230 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="b78234ca4f">
|
||||
<path d="M 343 231 L 352 231 L 352 241.671875 L 343 241.671875 Z M 343 231 " clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
<clipPath id="2b0e76334b">
|
||||
<path d="M 355 231 L 364.921875 231 L 364.921875 241.671875 L 355 241.671875 Z M 355 231 "
|
||||
clip-rule="nonzero" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#17e5ee130b)">
|
||||
<path fill="#060165"
|
||||
d="M 181.777344 1.269531 L 137.171875 1.269531 L 137.171875 53.847656 C 137.171875 78.601562 157.222656 98.652344 181.980469 98.652344 C 208.785156 98.652344 230.679688 76.761719 230.679688 49.960938 C 230.679688 23.160156 208.785156 1.269531 181.777344 1.269531 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<path fill="#ffffff"
|
||||
d="M 173.386719 31.140625 C 173.386719 36.253906 169.089844 40.550781 163.976562 40.550781 C 158.859375 40.550781 154.5625 44.847656 154.5625 49.960938 C 154.5625 55.074219 158.859375 59.371094 163.976562 59.371094 L 182.800781 59.371094 C 187.914062 59.371094 192.210938 55.074219 192.210938 49.960938 L 192.210938 31.140625 C 192.210938 26.023438 187.914062 21.726562 182.800781 21.726562 C 177.683594 21.726562 173.386719 26.023438 173.386719 31.140625 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
<path fill="#05c3ec"
|
||||
d="M 194.257812 68.78125 C 194.257812 63.667969 198.554688 59.371094 203.671875 59.371094 C 208.785156 59.371094 213.082031 55.074219 213.082031 49.960938 C 213.082031 44.847656 208.785156 40.550781 203.671875 40.550781 L 184.84375 40.550781 C 179.730469 40.550781 175.433594 44.847656 175.433594 49.960938 L 175.433594 68.78125 C 175.433594 73.898438 179.730469 78.191406 184.84375 78.191406 C 189.960938 78.191406 194.257812 73.898438 194.257812 68.78125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
<path fill="#060165"
|
||||
d="M 3.140625 205.640625 L 3.140625 124.460938 L 34.589844 124.460938 C 40.664062 124.460938 46.304688 125.542969 51.292969 127.5 C 56.28125 129.453125 60.835938 132.273438 64.304688 135.746094 C 67.992188 139.21875 70.8125 143.558594 72.765625 148.550781 C 74.71875 153.546875 75.800781 158.972656 75.800781 165.050781 C 75.800781 171.125 74.71875 176.554688 72.765625 181.546875 C 70.8125 186.539062 67.992188 190.878906 64.304688 194.351562 C 60.617188 198.042969 56.28125 200.648438 51.292969 202.816406 C 46.304688 204.769531 40.664062 205.855469 34.589844 205.855469 L 3.140625 205.855469 Z M 18.976562 190.226562 L 34.375 190.226562 C 38.0625 190.226562 41.53125 189.578125 44.570312 188.492188 C 47.605469 187.1875 50.207031 185.667969 52.378906 183.5 C 54.546875 181.328125 56.28125 178.722656 57.367188 175.46875 C 58.449219 172.429688 59.101562 168.957031 59.101562 165.050781 C 59.101562 161.140625 58.449219 157.886719 57.367188 154.628906 C 56.28125 151.589844 54.546875 148.988281 52.378906 146.816406 C 50.207031 144.644531 47.605469 142.910156 44.570312 141.824219 C 41.53125 140.738281 38.0625 140.085938 34.375 140.085938 L 18.976562 140.085938 C 18.976562 140.085938 18.976562 190.226562 18.976562 190.226562 Z M 18.976562 190.226562 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
<path fill="#060165"
|
||||
d="M 84.695312 173.949219 C 84.695312 169.171875 85.5625 164.832031 87.296875 160.925781 C 89.03125 157.019531 91.417969 153.546875 94.238281 150.507812 C 97.273438 147.46875 100.746094 145.296875 104.648438 143.558594 C 108.769531 141.824219 113.109375 141.171875 117.664062 141.171875 C 122.21875 141.171875 126.773438 142.039062 130.675781 143.558594 C 134.796875 145.296875 138.265625 147.46875 141.304688 150.507812 C 144.339844 153.546875 146.726562 157.019531 148.246094 160.925781 C 149.980469 164.832031 150.847656 169.171875 150.847656 173.949219 C 150.847656 178.507812 149.980469 182.847656 148.246094 186.753906 C 146.507812 190.660156 144.125 194.136719 141.304688 197.171875 C 138.265625 200.210938 134.796875 202.382812 130.675781 204.121094 C 126.554688 205.855469 122.21875 206.507812 117.664062 206.507812 C 113.109375 206.507812 108.554688 205.640625 104.648438 204.121094 C 100.527344 202.382812 97.058594 200.210938 94.019531 197.171875 C 90.984375 194.136719 88.816406 190.660156 87.082031 186.753906 C 85.34375 182.847656 84.695312 178.722656 84.695312 173.949219 M 100.527344 173.949219 C 100.527344 176.335938 100.960938 178.507812 101.828125 180.675781 C 102.695312 182.847656 103.78125 184.800781 105.300781 186.320312 C 106.816406 187.839844 108.554688 189.140625 110.722656 190.226562 C 112.890625 191.097656 115.277344 191.746094 117.878906 191.746094 C 120.480469 191.746094 122.867188 191.3125 124.820312 190.226562 C 126.988281 189.359375 128.722656 188.058594 130.242188 186.320312 C 131.761719 184.800781 132.84375 182.847656 133.710938 180.675781 C 134.582031 178.507812 135.015625 176.335938 135.015625 173.949219 C 135.015625 171.5625 134.582031 169.390625 133.710938 167.21875 C 132.84375 165.050781 131.761719 163.09375 130.242188 161.578125 C 128.722656 160.058594 126.988281 158.753906 124.820312 157.667969 C 122.652344 156.800781 120.480469 156.148438 117.878906 156.148438 C 115.277344 156.148438 112.890625 156.582031 110.722656 157.667969 C 108.554688 158.539062 106.816406 159.839844 105.300781 161.578125 C 103.78125 163.09375 102.695312 165.050781 101.828125 167.21875 C 100.960938 169.390625 100.527344 171.777344 100.527344 173.949219 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
<path fill="#060165"
|
||||
d="M 159.523438 173.949219 C 159.523438 169.171875 160.390625 164.832031 162.125 160.925781 C 163.859375 157.019531 166.246094 153.546875 169.066406 150.507812 C 172.101562 147.46875 175.574219 145.296875 179.476562 143.558594 C 183.597656 141.824219 187.9375 141.171875 192.492188 141.171875 C 196.613281 141.171875 200.515625 141.824219 203.988281 143.125 C 207.671875 144.429688 210.710938 146.164062 213.53125 148.335938 C 216.347656 150.507812 218.734375 153.328125 220.46875 156.367188 C 222.421875 159.621094 223.507812 163.09375 224.15625 166.785156 L 207.890625 166.785156 C 206.804688 163.53125 205.070312 161.140625 202.25 159.1875 C 199.648438 157.234375 196.394531 156.148438 192.492188 156.148438 C 189.886719 156.148438 187.503906 156.582031 185.332031 157.667969 C 183.164062 158.539062 181.429688 160.058594 179.910156 161.578125 C 178.394531 163.3125 177.308594 165.050781 176.441406 167.21875 C 175.574219 169.390625 175.140625 171.5625 175.140625 174.164062 C 175.140625 176.554688 175.574219 178.722656 176.441406 180.894531 C 177.308594 183.066406 178.394531 185.019531 179.910156 186.539062 C 181.429688 188.273438 183.164062 189.578125 185.332031 190.445312 C 187.503906 191.3125 189.886719 191.964844 192.492188 191.964844 C 196.179688 191.964844 199.433594 191.097656 202.25 189.140625 C 204.855469 187.1875 206.804688 184.800781 207.890625 181.546875 L 224.15625 181.546875 C 223.507812 185.234375 222.207031 188.707031 220.46875 191.746094 C 218.519531 194.785156 216.347656 197.609375 213.53125 199.777344 C 210.710938 201.949219 207.457031 203.683594 203.988281 204.988281 C 200.300781 206.289062 196.613281 206.941406 192.492188 206.941406 C 187.71875 206.941406 183.382812 206.074219 179.476562 204.554688 C 175.355469 202.816406 171.886719 200.648438 169.066406 197.609375 C 166.03125 194.570312 163.859375 191.097656 162.125 187.1875 C 160.390625 182.847656 159.523438 178.722656 159.523438 173.949219 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
<path fill="#060165"
|
||||
d="M 238.253906 124.460938 L 261.03125 187.1875 L 284.019531 124.460938 L 301.15625 124.460938 L 271.007812 205.640625 L 251.269531 205.640625 L 221.121094 124.460938 Z M 238.253906 124.460938 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
<g clip-path="url(#0262fc8075)">
|
||||
<path fill="#060165"
|
||||
d="M 315.035156 124.460938 L 334.558594 124.460938 L 364.921875 205.640625 L 348.65625 205.640625 L 343.015625 190.226562 L 307.011719 190.226562 L 301.15625 205.640625 L 284.886719 205.640625 Z M 338.246094 177.203125 L 325.015625 141.605469 L 311.78125 177.203125 Z M 338.246094 177.203125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#0914a8505c)">
|
||||
<path fill="#060165"
|
||||
d="M 7.261719 241.453125 L 3.359375 231.035156 L 5.3125 231.035156 L 8.5625 239.71875 L 11.816406 231.035156 L 13.554688 231.035156 L 9.648438 241.453125 Z M 7.261719 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#da83339c1d)">
|
||||
<path fill="#060165"
|
||||
d="M 17.675781 231.035156 L 19.410156 231.035156 L 19.410156 241.671875 L 17.675781 241.671875 Z M 17.675781 231.035156 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#97dbddcd78)">
|
||||
<path fill="#060165"
|
||||
d="M 25.046875 241.453125 L 25.046875 231.035156 L 29.601562 231.035156 C 31.773438 231.035156 33.722656 231.902344 33.722656 234.289062 C 33.722656 235.808594 32.855469 236.679688 31.773438 237.113281 C 32.421875 237.546875 32.640625 238.199219 32.855469 238.847656 C 33.074219 240.152344 33.507812 241.238281 33.722656 241.453125 L 31.773438 241.453125 C 31.554688 241.019531 31.339844 240.367188 31.121094 239.28125 C 30.90625 238.199219 30.472656 237.328125 29.601562 237.328125 L 27 237.328125 L 27 241.453125 Z M 26.785156 235.808594 L 29.167969 235.808594 C 30.472656 235.808594 31.773438 235.59375 31.773438 234.074219 C 31.773438 232.554688 30.6875 232.335938 29.386719 232.335938 L 26.785156 232.335938 Z M 26.785156 235.808594 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#3223358027)">
|
||||
<path fill="#060165"
|
||||
d="M 40.230469 241.453125 L 40.230469 232.554688 L 36.761719 232.554688 L 36.761719 231.035156 L 45.652344 231.035156 L 45.652344 232.554688 L 41.964844 232.554688 L 41.964844 241.453125 Z M 40.230469 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#f960afe448)">
|
||||
<path fill="#060165"
|
||||
d="M 51.292969 231.035156 L 51.292969 236.894531 C 51.292969 238.847656 51.941406 240.152344 54.113281 240.152344 C 56.0625 240.152344 56.933594 238.847656 56.933594 236.894531 L 56.933594 231.035156 L 58.667969 231.035156 L 58.667969 237.113281 C 58.667969 239.71875 57.148438 241.886719 54.113281 241.886719 C 51.074219 241.886719 49.558594 239.933594 49.558594 237.113281 L 49.558594 231.035156 Z M 51.292969 231.035156 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#04b92ea6b9)">
|
||||
<path fill="#060165"
|
||||
d="M 68.859375 238.632812 L 64.523438 238.632812 L 63.4375 241.453125 L 61.703125 241.453125 L 65.390625 231.035156 L 67.992188 231.035156 L 71.679688 241.453125 L 69.730469 241.453125 Z M 68.210938 237.113281 L 66.476562 232.335938 L 64.738281 237.113281 Z M 68.210938 237.113281 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#7b4adb9303)">
|
||||
<path fill="#060165"
|
||||
d="M 75.800781 241.453125 L 75.800781 231.035156 L 77.753906 231.035156 L 77.753906 239.933594 L 83.394531 239.933594 L 83.394531 241.453125 Z M 75.800781 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#53357fd403)">
|
||||
<path fill="#060165"
|
||||
d="M 100.3125 238.632812 L 95.972656 238.632812 L 94.886719 241.453125 L 93.152344 241.453125 L 96.839844 231.035156 L 99.441406 231.035156 L 103.128906 241.453125 L 101.179688 241.453125 Z M 99.660156 237.113281 L 97.925781 232.335938 L 96.191406 237.113281 Z M 99.660156 237.113281 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#bcd03dbca6)">
|
||||
<path fill="#060165"
|
||||
d="M 108.554688 237.980469 C 108.769531 238.632812 109.421875 240.152344 111.804688 240.152344 C 113.757812 240.152344 114.410156 239.5 114.410156 238.632812 C 114.410156 237.980469 113.542969 237.546875 112.457031 237.328125 L 110.722656 237.113281 C 107.683594 236.460938 107.035156 235.375 107.035156 234.074219 C 107.035156 232.335938 108.554688 231.035156 111.589844 231.035156 C 115.058594 231.035156 116.144531 232.769531 116.359375 234.074219 L 114.625 234.507812 C 114.410156 233.640625 113.324219 232.769531 111.589844 232.769531 C 110.070312 232.769531 108.769531 233.421875 108.769531 234.289062 C 108.769531 235.160156 109.636719 235.59375 110.9375 235.808594 L 112.671875 236.027344 C 115.492188 236.460938 116.144531 237.761719 116.144531 238.847656 C 116.144531 240.800781 114.410156 241.886719 111.589844 241.886719 C 108.117188 241.886719 106.816406 239.933594 106.601562 238.632812 Z M 108.554688 237.980469 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#ff8639fe11)">
|
||||
<path fill="#060165"
|
||||
d="M 122 237.980469 C 122.21875 238.632812 122.867188 240.152344 125.253906 240.152344 C 127.207031 240.152344 127.855469 239.5 127.855469 238.632812 C 127.855469 237.980469 126.988281 237.546875 125.90625 237.328125 L 124.167969 237.113281 C 121.132812 236.460938 120.480469 235.375 120.480469 234.074219 C 120.480469 232.335938 122 231.035156 125.035156 231.035156 C 128.507812 231.035156 129.589844 232.769531 129.808594 234.074219 L 128.289062 234.074219 C 128.074219 233.207031 126.988281 232.335938 125.253906 232.335938 C 123.734375 232.335938 122.433594 232.988281 122.433594 233.855469 C 122.433594 234.726562 123.300781 235.160156 124.601562 235.375 L 126.339844 235.59375 C 129.15625 236.027344 129.808594 237.328125 129.808594 238.414062 C 129.808594 240.367188 128.074219 241.453125 125.253906 241.453125 C 121.785156 241.453125 120.480469 239.5 120.265625 238.199219 Z M 122 237.980469 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#3990de54cb)">
|
||||
<path fill="#060165"
|
||||
d="M 134.796875 231.035156 L 136.53125 231.035156 L 136.53125 241.671875 L 134.796875 241.671875 Z M 134.796875 231.035156 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#045cbd7804)">
|
||||
<path fill="#060165"
|
||||
d="M 143.039062 237.980469 C 143.257812 238.632812 143.90625 240.152344 146.292969 240.152344 C 148.246094 240.152344 148.894531 239.5 148.894531 238.632812 C 148.894531 237.980469 148.027344 237.546875 146.941406 237.328125 L 145.207031 237.113281 C 142.171875 236.460938 141.519531 235.375 141.519531 234.074219 C 141.519531 232.335938 143.039062 231.035156 146.074219 231.035156 C 149.546875 231.035156 150.628906 232.769531 150.847656 234.074219 L 149.113281 234.507812 C 148.894531 233.640625 147.8125 232.769531 146.074219 232.769531 C 144.558594 232.769531 143.257812 233.421875 143.257812 234.289062 C 143.257812 235.160156 144.125 235.59375 145.425781 235.808594 L 147.160156 236.027344 C 149.980469 236.460938 150.628906 237.761719 150.628906 238.847656 C 150.628906 240.800781 148.894531 241.886719 146.074219 241.886719 C 142.605469 241.886719 141.304688 239.933594 141.085938 238.632812 Z M 143.039062 237.980469 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#7de9a85e20)">
|
||||
<path fill="#060165"
|
||||
d="M 157.570312 241.453125 L 157.570312 232.554688 L 154.101562 232.554688 L 154.101562 231.035156 L 162.992188 231.035156 L 162.992188 232.554688 L 159.304688 232.554688 L 159.304688 241.453125 Z M 157.570312 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#e68f5b3fba)">
|
||||
<path fill="#060165"
|
||||
d="M 172.753906 238.632812 L 168.414062 238.632812 L 167.332031 241.453125 L 165.597656 241.453125 L 169.285156 231.035156 L 171.886719 231.035156 L 175.574219 241.453125 L 173.839844 241.453125 Z M 172.320312 237.113281 L 170.585938 232.335938 L 168.847656 237.113281 Z M 172.320312 237.113281 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#65464ab719)">
|
||||
<path fill="#060165"
|
||||
d="M 179.042969 241.453125 L 179.042969 231.035156 L 180.777344 231.035156 L 186.636719 238.414062 L 186.636719 231.035156 L 188.371094 231.035156 L 188.371094 241.453125 L 186.851562 241.453125 L 180.996094 234.074219 L 180.996094 241.453125 Z M 179.042969 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#b82da71650)">
|
||||
<path fill="#060165"
|
||||
d="M 196.179688 241.453125 L 196.179688 232.554688 L 192.492188 232.554688 L 192.492188 231.035156 L 201.382812 231.035156 L 201.382812 232.554688 L 197.914062 232.554688 L 197.914062 241.453125 Z M 196.179688 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#fe4eaf6826)">
|
||||
<path fill="#060165"
|
||||
d="M 206.371094 237.980469 C 206.589844 238.632812 207.238281 240.152344 209.625 240.152344 C 211.578125 240.152344 212.226562 239.5 212.226562 238.632812 C 212.226562 237.980469 211.359375 237.546875 210.277344 237.328125 L 208.542969 237.113281 C 205.503906 236.460938 204.855469 235.375 204.855469 234.074219 C 204.855469 232.335938 206.371094 231.035156 209.410156 231.035156 C 212.878906 231.035156 213.964844 232.769531 214.179688 234.074219 L 212.445312 234.507812 C 212.226562 233.640625 211.144531 232.769531 209.410156 232.769531 C 207.890625 232.769531 206.589844 233.421875 206.589844 234.289062 C 206.589844 235.160156 207.457031 235.59375 208.757812 235.808594 L 210.492188 236.027344 C 213.3125 236.460938 213.964844 237.761719 213.964844 238.847656 C 213.964844 240.800781 212.230469 241.886719 209.410156 241.886719 C 205.9375 241.886719 204.636719 239.933594 204.421875 238.632812 Z M 206.371094 237.980469 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#79d174fbbf)">
|
||||
<path fill="#060165"
|
||||
d="M 225.460938 241.453125 L 225.460938 231.035156 L 233.050781 231.035156 L 233.050781 232.554688 L 227.195312 232.554688 L 227.195312 235.375 L 233.050781 235.375 L 233.050781 236.894531 L 227.195312 236.894531 L 227.195312 241.453125 Z M 225.460938 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#b0be87ffda)">
|
||||
<path fill="#060165"
|
||||
d="M 242.375 230.816406 C 245.414062 230.816406 248.015625 232.988281 248.015625 236.242188 C 248.015625 239.5 245.414062 241.671875 242.375 241.671875 C 239.339844 241.671875 236.738281 239.5 236.738281 236.242188 C 236.738281 232.988281 239.339844 230.816406 242.375 230.816406 M 246.28125 236.242188 C 246.28125 234.074219 244.546875 232.335938 242.375 232.335938 C 240.207031 232.335938 238.472656 234.074219 238.472656 236.242188 C 238.472656 238.414062 240.207031 240.152344 242.375 240.152344 C 244.546875 240.152344 246.28125 238.414062 246.28125 236.242188 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#444dbef42a)">
|
||||
<path fill="#060165"
|
||||
d="M 252.789062 241.453125 L 252.789062 231.035156 L 257.34375 231.035156 C 259.511719 231.035156 261.464844 231.902344 261.464844 234.289062 C 261.464844 235.808594 260.597656 236.679688 259.511719 237.113281 C 260.164062 237.546875 260.378906 238.199219 260.597656 238.847656 C 260.8125 240.152344 261.246094 241.238281 261.464844 241.453125 L 259.511719 241.453125 C 259.292969 241.019531 259.078125 240.367188 258.859375 239.28125 C 258.644531 238.199219 258.210938 237.328125 257.34375 237.328125 L 254.738281 237.328125 L 254.738281 241.453125 Z M 254.523438 235.808594 L 256.910156 235.808594 C 258.210938 235.808594 259.511719 235.59375 259.511719 234.074219 C 259.511719 232.554688 258.425781 232.335938 257.125 232.335938 L 254.523438 232.335938 Z M 254.523438 235.808594 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#314903e6e8)">
|
||||
<path fill="#060165"
|
||||
d="M 272.523438 241.453125 L 272.523438 230.816406 L 277.078125 230.816406 C 280.550781 230.816406 282.9375 232.988281 282.9375 236.027344 C 282.9375 239.28125 280.550781 241.238281 277.078125 241.238281 Z M 274.261719 239.933594 L 276.863281 239.933594 C 279.25 239.933594 280.984375 238.632812 280.984375 236.242188 C 280.984375 233.855469 279.25 232.554688 276.863281 232.554688 L 274.261719 232.554688 Z M 274.261719 239.933594 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#2fd70bc44d)">
|
||||
<path fill="#060165"
|
||||
d="M 292.480469 230.816406 C 295.515625 230.816406 298.117188 232.988281 298.117188 236.242188 C 298.117188 239.5 295.515625 241.671875 292.480469 241.671875 C 289.441406 241.671875 286.839844 239.5 286.839844 236.242188 C 286.839844 232.988281 289.226562 230.816406 292.480469 230.816406 M 296.382812 236.242188 C 296.382812 234.074219 294.648438 232.335938 292.480469 232.335938 C 290.3125 232.335938 288.574219 234.074219 288.574219 236.242188 C 288.574219 238.414062 290.3125 240.152344 292.480469 240.152344 C 294.648438 240.152344 296.382812 238.414062 296.382812 236.242188 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#032e9be536)">
|
||||
<path fill="#060165"
|
||||
d="M 310.480469 238.847656 L 311.78125 239.933594 C 310.699219 241.019531 309.179688 241.671875 307.664062 241.671875 C 304.625 241.671875 302.023438 239.5 302.023438 236.242188 C 302.023438 232.988281 304.625 230.816406 307.664062 230.816406 C 309.398438 230.816406 310.914062 231.46875 311.78125 232.554688 L 310.480469 233.640625 C 309.832031 232.769531 308.746094 232.335938 307.445312 232.335938 C 305.277344 232.335938 303.542969 234.074219 303.542969 236.242188 C 303.542969 238.414062 305.277344 240.152344 307.445312 240.152344 C 308.964844 240.152344 309.832031 239.71875 310.480469 238.847656 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#77ab9ec736)">
|
||||
<path fill="#060165"
|
||||
d="M 318.289062 241.453125 L 318.289062 232.554688 L 314.820312 232.554688 L 314.820312 231.035156 L 323.710938 231.035156 L 323.710938 232.554688 L 320.242188 232.554688 L 320.242188 241.453125 Z M 318.289062 241.453125 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#bf7e46828b)">
|
||||
<path fill="#060165"
|
||||
d="M 332.605469 230.816406 C 335.640625 230.816406 338.246094 232.988281 338.246094 236.242188 C 338.246094 239.5 335.640625 241.671875 332.605469 241.671875 C 329.570312 241.671875 326.964844 239.5 326.964844 236.242188 C 326.964844 232.988281 329.570312 230.816406 332.605469 230.816406 M 336.507812 236.242188 C 336.507812 234.074219 334.773438 232.335938 332.605469 232.335938 C 330.4375 232.335938 328.699219 234.074219 328.699219 236.242188 C 328.699219 238.414062 330.4375 240.152344 332.605469 240.152344 C 334.773438 240.152344 336.507812 238.414062 336.507812 236.242188 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#b78234ca4f)">
|
||||
<path fill="#060165"
|
||||
d="M 343.015625 241.453125 L 343.015625 231.035156 L 347.570312 231.035156 C 349.738281 231.035156 351.691406 231.902344 351.691406 234.289062 C 351.691406 235.808594 350.824219 236.679688 349.738281 237.113281 C 350.390625 237.546875 350.605469 238.199219 350.824219 238.847656 C 351.042969 240.152344 351.476562 241.238281 351.691406 241.453125 L 349.523438 241.453125 C 349.304688 241.019531 349.089844 240.367188 348.871094 239.28125 C 348.65625 238.199219 348.222656 237.328125 347.355469 237.328125 L 344.75 237.328125 L 344.75 241.453125 Z M 344.75 235.808594 L 347.136719 235.808594 C 348.4375 235.808594 349.738281 235.59375 349.738281 234.074219 C 349.738281 232.554688 348.65625 232.335938 347.355469 232.335938 L 344.75 232.335938 Z M 344.75 235.808594 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
<g clip-path="url(#2b0e76334b)">
|
||||
<path fill="#060165"
|
||||
d="M 357.113281 237.980469 C 357.332031 238.632812 357.980469 240.152344 360.367188 240.152344 C 362.320312 240.152344 362.96875 239.5 362.96875 238.632812 C 362.96875 237.980469 362.101562 237.546875 361.019531 237.328125 L 359.28125 237.113281 C 356.246094 236.460938 355.597656 235.375 355.597656 234.074219 C 355.597656 232.335938 357.113281 231.035156 360.152344 231.035156 C 363.621094 231.035156 364.707031 232.769531 364.921875 234.074219 L 363.1875 234.507812 C 362.96875 233.640625 361.886719 232.769531 360.152344 232.769531 C 358.632812 232.769531 357.332031 233.421875 357.332031 234.289062 C 357.332031 235.160156 358.199219 235.59375 359.5 235.808594 L 361.234375 236.027344 C 364.054688 236.460938 364.707031 237.761719 364.707031 238.847656 C 364.707031 240.800781 362.96875 241.886719 360.152344 241.886719 C 356.679688 241.886719 355.378906 239.933594 355.160156 238.632812 Z M 357.113281 237.980469 "
|
||||
fill-opacity="1" fill-rule="nonzero" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
// electron/ipc/apiHandler.ts
|
||||
import { ipcMain } from 'electron';
|
||||
import api from '../api/axiosClient.js';
|
||||
// // electron/ipc/apiHandler.ts
|
||||
// import { ipcMain } from 'electron';
|
||||
// import api from '../api/axiosClient.js';
|
||||
|
||||
interface ApiRequestArgs {
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
url: string;
|
||||
data?: any;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
// interface ApiRequestArgs {
|
||||
// method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
// url: string;
|
||||
// data?: any;
|
||||
// config?: Record<string, any>;
|
||||
// }
|
||||
|
||||
ipcMain.handle('api:request', async (_, args: ApiRequestArgs) => {
|
||||
try {
|
||||
const response = await api.request({
|
||||
method: args.method,
|
||||
url: args.url,
|
||||
data: args.data,
|
||||
...args.config,
|
||||
});
|
||||
// ipcMain.handle('api:request', async (_, args: ApiRequestArgs) => {
|
||||
// try {
|
||||
// const response = await api.request({
|
||||
// method: args.method,
|
||||
// url: args.url,
|
||||
// data: args.data,
|
||||
// ...args.config,
|
||||
// });
|
||||
|
||||
return { success: true, data: response };
|
||||
} catch (error: any) {
|
||||
console.error('[API Handler Error]', error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
};
|
||||
}
|
||||
});
|
||||
// return { success: true, data: response };
|
||||
// } catch (error: any) {
|
||||
// console.error('[API Handler Error]', error.message);
|
||||
// return {
|
||||
// success: false,
|
||||
// error: error.message,
|
||||
// status: error.response?.status,
|
||||
// };
|
||||
// }
|
||||
// });
|
||||
|
|
|
|||
|
|
@ -1,14 +1,33 @@
|
|||
// electron/api/axiosClient.ts
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { getToken } from '../utils/tokenHelper.js';
|
||||
import chalk from "chalk";
|
||||
import { sendTokenExpired } from '../ipc/ipcHandler.js';
|
||||
import https from 'https';
|
||||
import { getBaseURL } from './baseURL.js';
|
||||
|
||||
const TOKEN_KEY = 'ACCESS_TOKEN';
|
||||
|
||||
const Base_URL = await getBaseURL()
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
|
||||
console.log('Base_URL$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$' ,Base_URL)
|
||||
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: 'https://wired-terminally-muskrat.ngrok-free.app/api/v1/va', // 🔁 your backend
|
||||
// baseURL: 'https://portal-sf.docva.com/api/v1/va', // 🔁 your backend
|
||||
baseURL: `${Base_URL}/api/v1/va`,
|
||||
httpsAgent
|
||||
// timeout: 10000,
|
||||
});
|
||||
|
||||
// console.log("Creating Axios Instance", axios.VERSION);
|
||||
// console.log("Axios .get reference:", api.get);
|
||||
// console.log("Axios .request reference:", api.request);
|
||||
|
||||
// --- Request Interceptor ---
|
||||
api.interceptors.request.use(
|
||||
async (config: AxiosRequestConfig): Promise<any> => {
|
||||
|
|
@ -17,23 +36,42 @@ api.interceptors.request.use(
|
|||
|
||||
if (token) {
|
||||
config.headers['token'] = token;
|
||||
config.headers['ngrok-skip-browser-warning'] = 98098;
|
||||
config.headers['ngrok-skip-browser-warning'] = 'true';
|
||||
}
|
||||
|
||||
console.log(`[Axios] ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// --- Response Interceptor ---
|
||||
// api.interceptors.response.use(
|
||||
// (response: AxiosResponse) => response.data,
|
||||
// async (error) => {
|
||||
// console.error('[Axios Error]', error.status);
|
||||
// if (error.status === 401 || 403) {
|
||||
// console.warn('Token expired — trigger refresh logic if needed');
|
||||
// // sendTokenExpired();
|
||||
// }
|
||||
// return Promise.reject(error);
|
||||
// }
|
||||
// );
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response.data,
|
||||
async (error) => {
|
||||
console.error('[Axios Error]', error.message);
|
||||
if (error.response?.status === 401) {
|
||||
|
||||
console.log(error, "Line 46")
|
||||
const status = error?.response?.status;
|
||||
|
||||
console.error('[Axios Error]', status);
|
||||
|
||||
// Only logout when token actually expired
|
||||
if (status === 401 || status === 403) {
|
||||
console.warn('Token expired — trigger refresh logic if needed');
|
||||
sendTokenExpired();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
// let CURRENT_BASE_URL = "https://portal-sf.docva.com/api/v1/va";
|
||||
let CURRENT_BASE_URL = "https://portal-sf.docva.com";
|
||||
|
||||
export const getBaseURL = () => CURRENT_BASE_URL;
|
||||
export const setBaseURL = (url: string) => {
|
||||
CURRENT_BASE_URL = url;
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
import { getBaseURL } from "./baseURL.js";
|
||||
|
||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||
|
||||
export const checkRegion = async () => {
|
||||
const baseURL = await getBaseURL();
|
||||
|
||||
console.log("Lin 11 BaseURL CHECK@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@" ,baseURL)
|
||||
|
||||
try {
|
||||
|
||||
const res = await axios.get(`${baseURL}/health`, {
|
||||
headers: { "ngrok-skip-browser-warning": "true" },
|
||||
httpsAgent,
|
||||
timeout: 4000
|
||||
});
|
||||
|
||||
console.log(res , "checkRegion")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: res.status,
|
||||
data: res.data
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(error , "Line 29")
|
||||
const status = error?.response?.status || 500;
|
||||
|
||||
console.log("Region Check Error:", status);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
message: "Region check failed"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -1,47 +1,579 @@
|
|||
// electron/ipc/tokenHandler.ts
|
||||
import { ipcMain } from 'electron';
|
||||
import { getToken, saveToken, clearToken } from '../utils/tokenHelper.js';
|
||||
import { getActiveApp, handleLogin, takeScreenshot } from '../utils/utilityFunctions.js';
|
||||
import api from '../api/axiosClient.js';
|
||||
import { ipcMain, BrowserWindow } from "electron";
|
||||
import { getToken, saveToken, clearToken } from "../utils/tokenHelper.js";
|
||||
import { getActiveApp, handleLogin, takeScreenshot, startWindowPolling, getRunningAppsList, captureSpecificApp } from "../utils/utilityFunctions.js";
|
||||
import api from "../api/axiosClient.js";
|
||||
import { getMainWindow } from '../main.js';
|
||||
import { checkRegion } from "../api/regionCheck.js";
|
||||
import { startVPN, stopVPN } from "../vpn.js";
|
||||
import { getBaseURL, setBaseURL } from "../api/baseURL.js";
|
||||
|
||||
ipcMain.handle('get-token', async (_, TOKEN_KEY: "ACCESS_TOKEN" | "REFRESH_TOKEN") => {
|
||||
return await getToken(TOKEN_KEY);
|
||||
|
||||
|
||||
|
||||
export const registerIPCHandlers = (mainWindow: BrowserWindow) => {
|
||||
console.log("⚙️ Registering IPC Handlers...");
|
||||
|
||||
|
||||
ipcMain.handle("GET_BASE_URL", () => {
|
||||
return getBaseURL();
|
||||
});
|
||||
|
||||
ipcMain.handle('save-token', async (_, TOKEN_KEY: "ACCESS_TOKEN" | "REFRESH_TOKEN", token: string) => {
|
||||
await saveToken(TOKEN_KEY, token);
|
||||
// UPDATE Base URL
|
||||
ipcMain.handle("UPDATE_BASE_URL", (event, url: string) => {
|
||||
setBaseURL(url);
|
||||
api.defaults.baseURL = `${url}/api/v1/va`;
|
||||
console.log("Base URL updated to:", api.defaults.baseURL);
|
||||
return {
|
||||
updated: true,
|
||||
baseURL: url
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("get-token", async (_, key: "ACCESS_TOKEN" | "REFRESH_TOKEN") => {
|
||||
return await getToken(key);
|
||||
});
|
||||
|
||||
ipcMain.handle("save-token", async (_, key: "ACCESS_TOKEN" | "REFRESH_TOKEN", token: string) => {
|
||||
await saveToken(key, token);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-token', async (_, TOKEN_KEY: string) => {
|
||||
await clearToken(TOKEN_KEY);
|
||||
ipcMain.handle("clear-token", async (_, key: string) => {
|
||||
await clearToken(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("upload-screenshot", async () => {
|
||||
await takeScreenshot();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('upload-screenshot', async () => {
|
||||
takeScreenshot()
|
||||
})
|
||||
|
||||
// Handle login request from renderer
|
||||
ipcMain.handle('login-request', async (event, credentials: { email: string, password: string }) => {
|
||||
ipcMain.handle("login-request", async (_, credentials: { email: string; password: string }) => {
|
||||
return handleLogin(credentials);
|
||||
});
|
||||
|
||||
ipcMain.handle('logout-request', async () => {
|
||||
ipcMain.handle("logout-request", async () => {
|
||||
try {
|
||||
console.log("Handle Logout listened")
|
||||
console.log("Token", await getToken("ACCESS_TOKEN"))
|
||||
console.log("Handle Logout listened");
|
||||
console.log("Token", await getToken("ACCESS_TOKEN"));
|
||||
const res = await api.get("/logout");
|
||||
if(res && ("success" in res) && res.success) {
|
||||
return {success: true, message: "Logout Successfully"}
|
||||
if (res && "success" in res && res.success) {
|
||||
return { success: true, message: "Logout Successfully" };
|
||||
}
|
||||
return {success: false, message: ""}
|
||||
} catch(error) {
|
||||
return {success: false, message: ""}
|
||||
return { success: false, message: "" };
|
||||
} catch {
|
||||
return { success: false, message: "Logout failed" };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-active-app', () => {
|
||||
return getActiveApp();
|
||||
|
||||
|
||||
|
||||
|
||||
ipcMain.handle("get-active-app", async () => {
|
||||
return await getActiveApp();
|
||||
});
|
||||
|
||||
ipcMain.handle('toggle-close-btn', (event, shouldEnable: boolean) => {
|
||||
const mainWindow = getMainWindow()
|
||||
console.log("toggle-close-btn Called", mainWindow)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.setClosable(shouldEnable);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
ipcMain.handle("resize-window", (event, width: number, height: number) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
console.log(`📏 Resizing window to ${width}x${height}`);
|
||||
mainWindow.setResizable(true); // temporarily enable resizing
|
||||
mainWindow.setSize(width, height);
|
||||
mainWindow.setResizable(false); // disable again if you want to lock it
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ✅ Start background polling when main window is ready
|
||||
};
|
||||
// startWindowPolling(mainWindow);
|
||||
|
||||
|
||||
|
||||
ipcMain.handle("get-project-list", async () => {
|
||||
console.log("📡 get-project-list called");
|
||||
try {
|
||||
const res = await api.get("/list/projects");
|
||||
|
||||
console.log(" API /list/project response:", res);
|
||||
|
||||
// Return only serializable data
|
||||
return JSON.parse(JSON.stringify(res));
|
||||
} catch (error: any) {
|
||||
console.error(" get-project-list error:", error.status);
|
||||
return {
|
||||
success: false,
|
||||
message: error?.message || String(error) || "Unknown error",
|
||||
status: error?.status
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// When project tracking start
|
||||
ipcMain.handle("start-project-tracking", async (event, payload) => {
|
||||
try {
|
||||
console.log("🚀 start-project-tracking called with:", payload);
|
||||
|
||||
const response = await api.post("/log/project", payload);
|
||||
|
||||
console.log(" Tracking start synced:", response);
|
||||
return { success: true, data: response };
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error syncing project start:", error);
|
||||
|
||||
// If API sent a response (like 400)
|
||||
if (error.response) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response.data?.message || "Request failed",
|
||||
status: error.response.status
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Something went wrong",
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// When project tracking stop
|
||||
ipcMain.handle("stop-project-tracking", async (event, payload) => {
|
||||
try {
|
||||
console.log("stop-project-tracking called with:", payload);
|
||||
|
||||
const response = await api.post("/log/project", payload);
|
||||
|
||||
console.log(" Tracking stop synced:", response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(" Error syncing project stop:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// tabs opening and closing when and tracking start
|
||||
ipcMain.handle("tracking-tabs-opening-and-closing", async (event, payload) => {
|
||||
try {
|
||||
console.log("🟦 IPC received payload:", payload);
|
||||
|
||||
const response = await api.post("/log/project/track", payload);
|
||||
|
||||
console.log("🟩 Log track API success:", response);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Log track API error:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Something went wrong",
|
||||
error,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
ipcMain.handle("get-project-logs", async (event, { projectID, fromDate, toDate }) => {
|
||||
try {
|
||||
console.log("📥 get-project-logs called with:", { projectID, fromDate, toDate });
|
||||
|
||||
let url = `/report/project/${projectID}`;
|
||||
|
||||
|
||||
|
||||
// 🧠 Append query params only if dates are provided
|
||||
if (fromDate && toDate) {
|
||||
// const params = new URLSearchParams({
|
||||
// from: fromDate,
|
||||
// to: toDate,
|
||||
// });
|
||||
url += `/${fromDate}/${toDate}`;
|
||||
}
|
||||
|
||||
console.log("url", url)
|
||||
|
||||
const response = await api.get(url);
|
||||
console.log("✅ response-logs:", response);
|
||||
|
||||
return { success: true, data: response.data };
|
||||
} catch (error) {
|
||||
console.error("❌ Error fetching project logs:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("forgot-password", async (event, email: string) => {
|
||||
try {
|
||||
const response = await api.post("/forgot-password", { email });
|
||||
console.log(response, "Line response")
|
||||
if (response) {
|
||||
return { success: true, message: response.data?.message };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Forgot password error:", error.response?.data || error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "Something went wrong",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-running-apps', async () => {
|
||||
return await getRunningAppsList()
|
||||
});
|
||||
|
||||
ipcMain.handle('capture-specific-window', async (event, appTitle: string) => {
|
||||
console.log(appTitle, "IPC HANDLE")
|
||||
return await captureSpecificApp(appTitle)
|
||||
});
|
||||
|
||||
ipcMain.handle("get-profile", async () => {
|
||||
try {
|
||||
const response = await api.get("/profile");
|
||||
console.log(response.data)
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(" Profile fetch error:", error.response?.data || error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "Something went wrong",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("change-password", async (event, data) => {
|
||||
console.log("data-change-password", data)
|
||||
|
||||
|
||||
// Build final payload
|
||||
const payload = {
|
||||
userDetails: {
|
||||
oldPassword: data.oldPassword,
|
||||
password: data.password,
|
||||
cPassword: data.cPassword,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Change Password payload", payload)
|
||||
try {
|
||||
const response = await api.patch("/profile", payload);
|
||||
console.log("Chnage-passowrd-response", response)
|
||||
return { success: true, data: response.data };
|
||||
} catch (error: any) {
|
||||
console.error("❌ Change password error:", error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "Password update failed",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-project-summary", async (_, { projectId, date }) => {
|
||||
try {
|
||||
const url = `/timesheet/summary/${projectId}/${date}`;
|
||||
console.log("📡 Fetching summary:", url);
|
||||
|
||||
const res = await api.get(url);
|
||||
return {
|
||||
success: true,
|
||||
data: res.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error fetching summary:", error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "Something went wrong",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("create-timesheet", async (event, data) => {
|
||||
|
||||
console.log("data", data)
|
||||
try {
|
||||
console.log("📩 Timesheet Payload:", data);
|
||||
|
||||
const res = await api.post("/timesheet/adjustment/request", data);
|
||||
|
||||
console.log(res, " Line 323")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Timesheet created successfully",
|
||||
data: res,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ Timesheet Error:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "Failed to create timesheet",
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
ipcMain.handle("create-leave-request", async (event, payload) => {
|
||||
try {
|
||||
console.log("Leave Request Received:", payload);
|
||||
|
||||
const response = await api.post("/leave/request", payload);
|
||||
|
||||
console.log("Leave Saved:", response);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Leave Save Failed:");
|
||||
|
||||
let message = "Unknown error";
|
||||
|
||||
console.log(error.response)
|
||||
|
||||
if (error.response) {
|
||||
console.log("Status:", error.response.status);
|
||||
console.log("Message:", error.response.statusText);
|
||||
console.log("Server Response:", error.response.data);
|
||||
|
||||
// Extract the backend message properly
|
||||
message = error.response.data?.message || error.response.statusText;
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
ipcMain.handle("get-schedules", async () => {
|
||||
try {
|
||||
console.log(" Fetching schedules...");
|
||||
|
||||
const response = await api.get("/payrollSchedule"); // Backend API call
|
||||
|
||||
console.log("✅ Schedule Data:", response.data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: response.data
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Fetch Schedules Failed:");
|
||||
|
||||
let message = "Unknown error occurred";
|
||||
|
||||
if (error.response) {
|
||||
console.log("Status:", error.response.status);
|
||||
console.log("Message:", error.response.statusText);
|
||||
console.log("Server Response:", error.response.data);
|
||||
|
||||
message = error.response.data?.message || error.response.statusText;
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
ipcMain.handle("get-payslips", async (event, vaId: string) => {
|
||||
console.log("get-payslips", vaId)
|
||||
try {
|
||||
const response = await api.get(`/paySlip/${vaId}`);
|
||||
console.log(response.data, "Line 409")
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
let message = "Unknown error occurred";
|
||||
|
||||
if (error.response) {
|
||||
console.log("Status:", error.response.status);
|
||||
console.log("Message:", error.response.statusText);
|
||||
console.log("Server Response:", error.response.data);
|
||||
|
||||
message = error.response.data?.message || error.response.statusText;
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
ipcMain.handle("get-leave-list", async () => {
|
||||
|
||||
try {
|
||||
const res = await api.get("/leaves/all");
|
||||
|
||||
console.log("/leaves/all", res.data);
|
||||
return {
|
||||
success: true,
|
||||
data: res.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
let message = "Unknown error occurred";
|
||||
|
||||
if (error.response) {
|
||||
console.log("Status:", error.response.status);
|
||||
console.log("Message:", error.response.statusText);
|
||||
console.log("Server Response:", error.response.data);
|
||||
|
||||
message = error.response.data?.message || error.response.statusText;
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-adjustments-list", async (event, vaId: string) => {
|
||||
|
||||
try {
|
||||
const res = await api.get(`/timesheet/adjustments/list?vaId=${vaId}`);
|
||||
|
||||
console.log("/timesheet/adjustments/list", res.data);
|
||||
return {
|
||||
success: true,
|
||||
data: res.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
let message = "Unknown error occurred";
|
||||
|
||||
if (error.response) {
|
||||
console.log("Status:", error.response.status);
|
||||
console.log("Message:", error.response.statusText);
|
||||
console.log("Server Response:", error.response.data);
|
||||
|
||||
message = error.response.data?.message || error.response.statusText;
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
ipcMain.handle("update-payment", async (event, data: Record<string, any>) => {
|
||||
console.log("data-update-payment", data);
|
||||
|
||||
// Build userDetails object
|
||||
const userDetails: Record<string, any> = {
|
||||
paymentMethod: data.paymentMethod,
|
||||
};
|
||||
|
||||
// paymentMethod: 'payoneeraccount',
|
||||
// payoneerAccount: 'check@gmail.com'
|
||||
|
||||
// Add payment-specific fields
|
||||
if (data.paymentMethod === "wise" && data.wiseEmail) {
|
||||
userDetails.wiseEmail = data.wiseEmail;
|
||||
// Clear veemAccount when switching to wise
|
||||
userDetails.veemAccount = "";
|
||||
userDetails.payoneerAccount = ""
|
||||
} else if (data.paymentMethod === "veem" && data.veemAccount) {
|
||||
userDetails.veemAccount = data.veemAccount;
|
||||
// Clear wiseEmail when switching to veem
|
||||
userDetails.wiseEmail = "";
|
||||
userDetails.payoneerAccount = ""
|
||||
} else if (data.paymentMethod === "payoneeraccount" && data.payoneerAccount) {
|
||||
userDetails.payoneerAccount = data.payoneerAccount;
|
||||
userDetails.veemAccount = "";
|
||||
userDetails.wiseEmail = ""
|
||||
}
|
||||
|
||||
// Build final payload
|
||||
const payload = {
|
||||
userDetails: userDetails,
|
||||
};
|
||||
|
||||
console.log("Update Payment payload", payload);
|
||||
|
||||
try {
|
||||
const response = await api.patch("/profile", payload);
|
||||
console.log("Update-payment-response", response);
|
||||
return { success: true, data: response?.data?.userDetails };
|
||||
} catch (error: any) {
|
||||
console.error("❌ Update payment error:", error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "Payment update failed",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
ipcMain.handle("check-region", async () => {
|
||||
return await checkRegion();
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const sendTokenExpired = () => {
|
||||
console.log("⚠️ Sending token-expired event to renderer");
|
||||
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send("token-expired");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
ipcMain.handle("vpn-connect", async () => {
|
||||
return startVPN();
|
||||
});
|
||||
|
||||
ipcMain.handle("vpn-disconnect", async () => {
|
||||
stopVPN();
|
||||
});
|
||||
|
|
@ -1,80 +1,239 @@
|
|||
import { app, BrowserWindow } from 'electron';
|
||||
import { ipcMainHandle, ipcMainOn, isDev } from './util.js';
|
||||
import { getStaticData, pollResources } from './resourceManager.js';
|
||||
import { getPreloadPath, getUIPath } from './pathResolver.js';
|
||||
import { app, BrowserWindow, globalShortcut, session } from "electron";
|
||||
import { isDev } from "./util.js";
|
||||
// import { pollResources } from "./resourceManager.js";
|
||||
import { getPreloadPath, getUIPath } from "./pathResolver.js";
|
||||
import { registerIPCHandlers } from "./ipc/ipcHandler.js";
|
||||
|
||||
import "./ipc/ipcHandler.js"
|
||||
import "./ipc/ipcHandler.js";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
app.on('ready', async () => {
|
||||
console.log("ready")
|
||||
const mainWindow = new BrowserWindow({
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
},
|
||||
import * as fs from "fs";
|
||||
import { execFile } from "child_process";
|
||||
|
||||
resizable: false, // disables resizing (no maximize)
|
||||
minimizable: false, // disables minimize button
|
||||
maximizable: false, // disables maximize button
|
||||
closable: true,
|
||||
alwaysOnTop: true,
|
||||
// disables default system frame (dont do this if you want a proper working menu bar)
|
||||
frame: process.platform === 'darwin' ? false : true,
|
||||
autoHideMenuBar: true
|
||||
});
|
||||
app.setAppUserModelId("com.tracker.app");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
if (isDev()) {
|
||||
mainWindow.loadURL('http://localhost:5123');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(getUIPath());
|
||||
function resolveIcon() {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
if (isDev) {
|
||||
// DEV mode → use project root path
|
||||
return path.join(process.cwd(), "src", "assets", "icons", "appIcon.ico");
|
||||
}
|
||||
|
||||
pollResources(mainWindow);
|
||||
console.log("Line 24", path.join(__dirname, "..", "assets", "icons", "appIcon.ico"))
|
||||
|
||||
ipcMainHandle('getStaticData', () => {
|
||||
return getStaticData();
|
||||
const prodPath = path.join(process.resourcesPath, "assets", "icons", "appIcon.ico");
|
||||
console.log("PROD Icon Path:", prodPath);
|
||||
return prodPath;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// SINGLE INSTANCE LOCK
|
||||
// ---------------------------
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let splashWindow: BrowserWindow | null = null;
|
||||
|
||||
// ---------------------------
|
||||
// HANDLE DOUBLE OPEN ATTEMPT
|
||||
// ---------------------------
|
||||
app.on("second-instance", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ipcMainOn('sendFrameAction', (payload) => {
|
||||
// switch (payload) {
|
||||
// case 'CLOSE':
|
||||
// mainWindow.close();
|
||||
// break;
|
||||
// case 'MAXIMIZE':
|
||||
// mainWindow.maximize();
|
||||
// break;
|
||||
// case 'MINIMIZE':
|
||||
// mainWindow.minimize();
|
||||
// break;
|
||||
|
||||
// CHECKING IF WIREGUARD EXIST - IF NOT INSTALLING IN CLIENT MACHINE
|
||||
const wgPath = "C:\\Program Files\\WireGuard\\wireguard.exe";
|
||||
|
||||
if (!fs.existsSync(wgPath)) {
|
||||
const installer = path.join(process.resourcesPath, "wireguard-install", "wireguard-installer.exe");
|
||||
|
||||
execFile(installer, ["/quiet"], (err) => {
|
||||
if (err) console.error("WireGuard install failed:", err);
|
||||
else console.log("WireGuard installed successfully.");
|
||||
});
|
||||
}
|
||||
// CHECKING IF WIREGUARD EXIST - IF NOT INSTALLING IN CLIENT MACHINE
|
||||
|
||||
|
||||
// ---------------------------
|
||||
// MAIN APP READY
|
||||
// ---------------------------
|
||||
app.on("ready", async () => {
|
||||
|
||||
splashWindow = new BrowserWindow({
|
||||
width: 300,
|
||||
height: 300,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
alwaysOnTop: false, // splash does NOT need to be on top || Yooo Don't make it onTop
|
||||
resizable: false,
|
||||
show: true,
|
||||
skipTaskbar: true,
|
||||
webPreferences: {
|
||||
devTools: false,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
splashWindow.loadFile(
|
||||
path.join(__dirname, "../assets/splash.html")
|
||||
);
|
||||
|
||||
|
||||
// Create the main window
|
||||
const win = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 600,
|
||||
resizable: false,
|
||||
fullscreenable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
closable: true,
|
||||
// alwaysOnTop: true,
|
||||
// skipTaskbar: true,
|
||||
autoHideMenuBar: true,
|
||||
frame: true,
|
||||
show: false,
|
||||
icon: resolveIcon(),
|
||||
webPreferences: {
|
||||
devTools: true,
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// win.setAlwaysOnTop(true, 'screen-saver');
|
||||
// win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
// win.setFullScreenable(false);
|
||||
|
||||
// Cross-platform safe always-on-top
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
if (isMac) {
|
||||
win.setAlwaysOnTop(true, "floating"); // best for macOS
|
||||
win.setVisibleOnAllWorkspaces(true); // NO fullscreen override
|
||||
} else {
|
||||
win.setAlwaysOnTop(true, "normal"); // safest for Windows + Zoom
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
win.setFullScreenable(false);
|
||||
}
|
||||
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// ------------------------------------
|
||||
// ONLY LOG NETWORK REQUESTS IN DEV
|
||||
// ------------------------------------
|
||||
if (isDev() && process.env.LOG_REQUESTS === "true") {
|
||||
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
console.log("🌐 Request:", details.method, details.url);
|
||||
callback({ cancel: false });
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// LOAD FRONTEND
|
||||
// ------------------------------------
|
||||
if (isDev()) {
|
||||
win.loadURL("http://localhost:5123");
|
||||
win.webContents.openDevTools(); // Optional (slows startup)
|
||||
} else {
|
||||
win.loadFile(getUIPath());
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// SHOW WINDOW WHEN READY (no white flash)
|
||||
// ------------------------------------
|
||||
win.once("ready-to-show", () => {
|
||||
// Delay heavy work to improve startup speed
|
||||
setTimeout(() => {
|
||||
splashWindow?.close();
|
||||
splashWindow = null;
|
||||
|
||||
win.show();
|
||||
// pollResources(win);
|
||||
registerIPCHandlers(win);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// ------------------------------------
|
||||
// HANDLE CLOSE EVENTS
|
||||
// ------------------------------------
|
||||
// handleCloseEvents(win);
|
||||
mainWindow.on("close", () => {
|
||||
if(mainWindow !== null) {
|
||||
mainWindow.webContents.executeJavaScript(`window.__allowClose__ = true`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
app.on('browser-window-focus', () => {
|
||||
globalShortcut.register('CommandOrControl+R', () => {
|
||||
// Do nothing; disables reload
|
||||
});
|
||||
globalShortcut.register('F5', () => {
|
||||
// Do nothing; disables reload
|
||||
});
|
||||
globalShortcut.register('CommandOrControl+Shift+I', () => {
|
||||
// Do nothing; disables DevTools
|
||||
});
|
||||
});
|
||||
|
||||
// app.on('browser-window-blur', () => {
|
||||
// globalShortcut.unregisterAll();
|
||||
// });
|
||||
|
||||
|
||||
|
||||
// ---------------------------
|
||||
// CUSTOM CLOSE BEHAVIOR
|
||||
// ---------------------------
|
||||
// function handleCloseEvents(window: BrowserWindow) {
|
||||
// let willClose = false;
|
||||
|
||||
// window.on("close", (e) => {
|
||||
// if (willClose) return;
|
||||
|
||||
// e.preventDefault();
|
||||
// window.hide();
|
||||
|
||||
// if (app.dock) {
|
||||
// app.dock.hide();
|
||||
// }
|
||||
// });
|
||||
|
||||
// createTray(mainWindow);
|
||||
handleCloseEvents(mainWindow);
|
||||
// createMenu(mainWindow);
|
||||
// await takeScreenshot();
|
||||
});
|
||||
// app.on("before-quit", () => {
|
||||
// willClose = true;
|
||||
// });
|
||||
|
||||
function handleCloseEvents(mainWindow: BrowserWindow) {
|
||||
let willClose = false;
|
||||
// window.on("show", () => {
|
||||
// willClose = false;
|
||||
// });
|
||||
// }
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
if (willClose) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
mainWindow.hide();
|
||||
if (app.dock) {
|
||||
app.dock.hide();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
willClose = true;
|
||||
});
|
||||
|
||||
mainWindow.on('show', () => {
|
||||
willClose = false;
|
||||
});
|
||||
// ---------------------------
|
||||
// GET MAIN WINDOW EXPORT
|
||||
// ---------------------------
|
||||
export function getMainWindow() {
|
||||
return mainWindow;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,28 @@
|
|||
// import path from 'path';
|
||||
// import { app } from 'electron';
|
||||
// import { isDev } from './util.js';
|
||||
|
||||
// export function getPreloadPath() {
|
||||
// return path.join(
|
||||
// app.getAppPath(),
|
||||
// isDev() ? '.' : '..',
|
||||
// '/dist-electron/preload.cjs'
|
||||
// );
|
||||
// }
|
||||
|
||||
// export function getUIPath() {
|
||||
// return path.join(app.getAppPath(), "dist-react", "index.html");
|
||||
|
||||
// // return "C:\\Users\\user\\OneDrive\\Desktop\\electron-main\\dist-react\\index.html";
|
||||
// // return path.join(app.getAppPath(), '/dist-react/index.html');
|
||||
// }
|
||||
|
||||
// export function getAssetPath() {
|
||||
// return path.join(app.getAppPath(), isDev() ? '.' : '..', '/src/assets');
|
||||
// }
|
||||
|
||||
|
||||
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { isDev } from './util.js';
|
||||
|
|
@ -6,14 +31,22 @@ export function getPreloadPath() {
|
|||
return path.join(
|
||||
app.getAppPath(),
|
||||
isDev() ? '.' : '..',
|
||||
'/dist-electron/preload.cjs'
|
||||
'/dist-electron/preload.cjs' // ✅ Changed to .js
|
||||
);
|
||||
}
|
||||
|
||||
export function getUIPath() {
|
||||
return path.join(app.getAppPath(), '/dist-react/index.html');
|
||||
return path.join(app.getAppPath(), "dist-react", "index.html");
|
||||
}
|
||||
|
||||
export function getAssetPath() {
|
||||
return path.join(app.getAppPath(), isDev() ? '.' : '..', '/src/assets');
|
||||
return path.join(app.getAppPath(), isDev() ? '.' : '..', '/assets');
|
||||
}
|
||||
|
||||
export function getIconPath() {
|
||||
return path.join(getAssetPath(), 'icons', 'appIcon.ico');
|
||||
}
|
||||
|
||||
export function getSplashPath() {
|
||||
return path.join(getAssetPath(), 'splash.html');
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
var electron_1 = require("electron");
|
||||
console.log("setting PreLoad");
|
||||
electron_1.contextBridge.exposeInMainWorld('electron', {
|
||||
login: function (credentials) { return electron_1.ipcRenderer.invoke('login-request', credentials); },
|
||||
logout: function () { return electron_1.ipcRenderer.invoke('logout-request'); },
|
||||
forgotPassword: function (email) { return electron_1.ipcRenderer.invoke("forgot-password", email); },
|
||||
getToken: function (TOKEN_KEY) { return electron_1.ipcRenderer.invoke('get-token', TOKEN_KEY); },
|
||||
uploadScreenshot: function () { return electron_1.ipcRenderer.invoke('upload-screenshot'); },
|
||||
getActiveAppDetails: function () { return electron_1.ipcRenderer.invoke('get-active-app'); },
|
||||
toggleCloseBtn: function (shouldEnable) { return electron_1.ipcRenderer.invoke('toggle-close-btn', shouldEnable); },
|
||||
resizeWindow: function (width, height) { return electron_1.ipcRenderer.invoke('resize-window', width, height); },
|
||||
getProjectList: function () { return electron_1.ipcRenderer.invoke("get-project-list"); },
|
||||
onWindowUpdate: function (callback) {
|
||||
return electron_1.ipcRenderer.on("window:updated", function (_, data) { return callback(data); });
|
||||
},
|
||||
startProjectTracking: function (data) { return electron_1.ipcRenderer.invoke("start-project-tracking", data); },
|
||||
stopProjectTracking: function (data) { return electron_1.ipcRenderer.invoke("stop-project-tracking", data); },
|
||||
trackingTabsOpeningClosing: function (data) {
|
||||
return electron_1.ipcRenderer.invoke("tracking-tabs-opening-and-closing", data);
|
||||
},
|
||||
getProjectLogs: function (projectID, fromDate, toDate) { return electron_1.ipcRenderer.invoke("get-project-logs", { projectID: projectID, fromDate: fromDate, toDate: toDate }); },
|
||||
});
|
||||
// function ipcInvoke<Key extends keyof EventPayloadMapping>(
|
||||
// key: Key
|
||||
// ): Promise<EventPayloadMapping[Key]> {
|
||||
// return ipcRenderer.invoke(key);
|
||||
// }
|
||||
// function ipcOn<Key extends keyof EventPayloadMapping>(
|
||||
// key: Key,
|
||||
// callback: (payload: EventPayloadMapping[Key]) => void
|
||||
// ) {
|
||||
// const cb = (_: Electron.IpcRendererEvent, payload: any) => callback(payload);
|
||||
// ipcRenderer.on(key, cb);
|
||||
// return () => ipcRenderer.off(key, cb);
|
||||
// }
|
||||
// function ipcSend<Key extends keyof EventPayloadMapping>(
|
||||
// key: Key,
|
||||
// payload: EventPayloadMapping[Key]
|
||||
// ) {
|
||||
// ipcRenderer.send(key, payload);
|
||||
// }
|
||||
|
|
@ -5,9 +5,74 @@ console.log("setting PreLoad")
|
|||
contextBridge.exposeInMainWorld('electron', {
|
||||
login: (credentials: { email: string, password: string }) => ipcRenderer.invoke('login-request', credentials),
|
||||
logout: () => ipcRenderer.invoke('logout-request'),
|
||||
forgotPassword: (email: string) => ipcRenderer.invoke("forgot-password", email),
|
||||
getToken: (TOKEN_KEY: "ACCESS_TOKEN" | "REFRESH_TOKEN") => ipcRenderer.invoke('get-token', TOKEN_KEY),
|
||||
uploadScreenshot: () => ipcRenderer.invoke('upload-screenshot'),
|
||||
getActiveAppDetails: () => ipcRenderer.invoke('get-active-app'),
|
||||
toggleCloseBtn: (shouldEnable: boolean) => ipcRenderer.invoke('toggle-close-btn', shouldEnable),
|
||||
resizeWindow: (width: number, height: number) => ipcRenderer.invoke('resize-window', width, height),
|
||||
getProjectList: () => ipcRenderer.invoke("get-project-list"),
|
||||
getRunningApps: () => ipcRenderer.invoke('get-running-apps'),
|
||||
captureAppScreenShot: (appTitle: string): Promise<string> => ipcRenderer.invoke('capture-specific-window', appTitle),
|
||||
onWindowUpdate: (callback) =>
|
||||
ipcRenderer.on("window:updated", (_, data) => callback(data)),
|
||||
|
||||
|
||||
startProjectTracking: (data) => ipcRenderer.invoke("start-project-tracking", data),
|
||||
stopProjectTracking: (data) => ipcRenderer.invoke("stop-project-tracking", data),
|
||||
trackingTabsOpeningClosing: (data) =>
|
||||
ipcRenderer.invoke("tracking-tabs-opening-and-closing", data),
|
||||
|
||||
getProjectLogs: (
|
||||
projectID: string,
|
||||
fromDate?: string,
|
||||
toDate?: string
|
||||
) => ipcRenderer.invoke("get-project-logs", { projectID, fromDate, toDate }),
|
||||
|
||||
getProfile: () => ipcRenderer.invoke("get-profile"),
|
||||
|
||||
changePassword: (data: { oldPassword: string; password: string; cPassword: string }) =>
|
||||
ipcRenderer.invoke("change-password", data),
|
||||
|
||||
updatePayment: (data: {
|
||||
paymentMethod: string;
|
||||
wiseEmail?: string;
|
||||
veemAccount?: string;
|
||||
}) => ipcRenderer.invoke('update-payment', data),
|
||||
|
||||
onTokenExpired: (callback) => {
|
||||
// Remove old listeners
|
||||
ipcRenderer.removeAllListeners("token-expired");
|
||||
|
||||
// Add single-use listener
|
||||
ipcRenderer.once("token-expired", () => {
|
||||
console.log("⚠️ Token expired event received in PRELOAD (ONE TIME)");
|
||||
callback();
|
||||
});
|
||||
},
|
||||
|
||||
getProjectSummary: (projectId: string, date: string) =>
|
||||
ipcRenderer.invoke("get-project-summary", { projectId, date }),
|
||||
|
||||
createTimesheet: (data: any) => ipcRenderer.invoke('create-timesheet', data),
|
||||
|
||||
createPTOAndSickLeaveRequest: (data) =>
|
||||
ipcRenderer.invoke("create-leave-request", data),
|
||||
|
||||
getSchedules: () => ipcRenderer.invoke("get-schedules"),
|
||||
|
||||
getPayslips: (vaId: string) => ipcRenderer.invoke("get-payslips", vaId),
|
||||
getLeaveList: () => ipcRenderer.invoke("get-leave-list"),
|
||||
getAdjustmentsList:(vaId: string) => ipcRenderer.invoke("get-adjustments-list",vaId),
|
||||
|
||||
checkRegion: () => ipcRenderer.invoke("check-region"),
|
||||
|
||||
connectVPN: () => ipcRenderer.invoke("vpn-connect"),
|
||||
disconnectVPN: () => ipcRenderer.invoke("vpn-disconnect"),
|
||||
|
||||
getBaseURL: () => ipcRenderer.invoke("GET_BASE_URL"),
|
||||
updateBaseURL: (url: string) => ipcRenderer.invoke("UPDATE_BASE_URL", url),
|
||||
|
||||
} satisfies Window['electron']);
|
||||
|
||||
// function ipcInvoke<Key extends keyof EventPayloadMapping>(
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ test('', () => {
|
|||
expect(template[0].label).toEqual('Show');
|
||||
template[0]?.click?.(null as any, null as any, null as any);
|
||||
expect(mainWindow.show).toHaveBeenCalled();
|
||||
expect(app.dock.show).toHaveBeenCalled();
|
||||
expect(app.dock!.show).toHaveBeenCalled();
|
||||
|
||||
template[1]?.click?.(null as any, null as any, null as any);
|
||||
expect(app.quit).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function ipcMainHandle<Key extends keyof EventPayloadMapping>(
|
|||
handler: () => EventPayloadMapping[Key]
|
||||
) {
|
||||
ipcMain.handle(key, (event) => {
|
||||
validateEventFrame(event.senderFrame);
|
||||
validateEventFrame(event.senderFrame!);
|
||||
return handler();
|
||||
});
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ export function ipcMainOn<Key extends keyof EventPayloadMapping>(
|
|||
handler: (payload: EventPayloadMapping[Key]) => void
|
||||
) {
|
||||
ipcMain.on(key, (event, payload) => {
|
||||
validateEventFrame(event.senderFrame);
|
||||
validateEventFrame(event.senderFrame!);
|
||||
return handler(payload);
|
||||
});
|
||||
}
|
||||
|
|
@ -34,11 +34,16 @@ export function ipcWebContentsSend<Key extends keyof EventPayloadMapping>(
|
|||
webContents.send(key, payload);
|
||||
}
|
||||
|
||||
export function validateEventFrame(frame: WebFrameMain) {
|
||||
if (isDev() && new URL(frame.url).host === 'localhost:5123') {
|
||||
export function validateEventFrame(frame: WebFrameMain | null): asserts frame is WebFrameMain {
|
||||
if (!frame) throw new Error('Missing frame');
|
||||
|
||||
const url = frame.url;
|
||||
|
||||
if (isDev() && new URL(url).host === 'localhost:5123') {
|
||||
return;
|
||||
}
|
||||
if (frame.url !== pathToFileURL(getUIPath()).toString()) {
|
||||
if (url !== pathToFileURL(getUIPath()).toString()) {
|
||||
throw new Error('Malicious event');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { desktopCapturer } from "electron";
|
||||
import { app, BrowserWindow, desktopCapturer } from "electron";
|
||||
import * as fs from "fs";
|
||||
import path from "path";
|
||||
import axios from 'axios';
|
||||
import https from 'https';
|
||||
import sharp from 'sharp';
|
||||
import { saveToken } from "./tokenHelper.js";
|
||||
import { activeWindow } from "get-windows";
|
||||
import { exec } from "child_process";
|
||||
import { getUIPath } from "../pathResolver.js";
|
||||
import { getBaseURL } from "../api/baseURL.js";
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
|
||||
// Blur using an offscreen canvas
|
||||
async function blurAndCompressBase64Image(base64: string): Promise<Buffer> {
|
||||
|
|
@ -53,12 +62,18 @@ export const handleLogin = async (credentials: { email: string, password: string
|
|||
// Call your login API
|
||||
console.log("🟡 login Request received", credentials);
|
||||
|
||||
let baseURL = getBaseURL()
|
||||
|
||||
console.log("app.getAppPath()", app.getAppPath());
|
||||
console.log("UIPATH", getUIPath());
|
||||
console.log("Doom 1" , `${baseURL}/api/v1/va/login`);
|
||||
|
||||
try {
|
||||
console.log("➡️ Calling API...");
|
||||
const response = await axios.post(
|
||||
'https://wired-terminally-muskrat.ngrok-free.app/api/v1/va/login',
|
||||
`${baseURL}/api/v1/va/login`,
|
||||
{ userDetails: { ...credentials } },
|
||||
{ headers: { "ngrok-skip-browser-warning": 69420 } }
|
||||
{ headers: { "ngrok-skip-browser-warning": 69420 }, httpsAgent }
|
||||
);
|
||||
|
||||
console.log("API CAlled", response)
|
||||
|
|
@ -95,7 +110,89 @@ export const handleLogin = async (credentials: { email: string, password: string
|
|||
}
|
||||
}
|
||||
|
||||
// Get active window info with timestamp
|
||||
export const getActiveApp = async () => {
|
||||
const result = await activeWindow();
|
||||
return result;
|
||||
const dataWithTime = { ...result, timestamp: new Date().toISOString() };
|
||||
// console.log("🪟 Active Window with Time:", dataWithTime);
|
||||
return dataWithTime;
|
||||
};
|
||||
|
||||
// Periodically send active window updates
|
||||
export const startWindowPolling = (win: BrowserWindow) => {
|
||||
console.log("⏱️ Started active window polling (every 5s)");
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const app = await getActiveApp();
|
||||
if (app) win.webContents.send("window:updated", app);
|
||||
} catch (err) {
|
||||
console.error("❌ Polling error:", err);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
|
||||
export const getRunningAppsList = async () => {
|
||||
try {
|
||||
// PowerShell command: Get only apps with visible windows (non-background)
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 } // Optional: set thumbnail size
|
||||
});
|
||||
|
||||
console.log(sources, "Sources Retrieved");
|
||||
|
||||
const app = sources.map(source => ({
|
||||
name: source.name,
|
||||
pid: source.id,
|
||||
// thumbnail: source.thumbnail // Can be used as screenshot
|
||||
}));
|
||||
|
||||
return app
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const captureSpecificApp = async (windowTitle: string): Promise<string> => {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 650, height: 430 }
|
||||
});
|
||||
const targetSource = sources.find(source => source.name === windowTitle);
|
||||
if (targetSource) {
|
||||
|
||||
let image = targetSource.thumbnail.toDataURL();
|
||||
const blurredBuffer = await blurAndCompressBase64Image(image);
|
||||
|
||||
// Convert buffer → base64 string
|
||||
const base64String = `data:image/png;base64,${blurredBuffer.toString('base64')}`;
|
||||
|
||||
// ✅ Return the processed base64 instead of saving
|
||||
return base64String;
|
||||
|
||||
// const folderPath = "C:\\Users\\Public\\vatracker\\screenshots";
|
||||
|
||||
// // Ensure directory exists
|
||||
// if (!fs.existsSync(folderPath)) {
|
||||
// fs.mkdirSync(folderPath, { recursive: true });
|
||||
// }
|
||||
|
||||
// const filePath = path.join(folderPath, `screenshot-${Date.now()}.png`);
|
||||
// fs.writeFileSync(filePath, blurredBuffer);
|
||||
|
||||
// console.log(`Screenshot saved to ${filePath}`);
|
||||
} else {
|
||||
console.log(`Window with title "${windowTitle}" not found.`);
|
||||
return "Not Found"
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error capturing screenshot:', error);
|
||||
return 'Failed to capture screenshot.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// timmer stop after 4-5 sec check it..
|
||||
|
|
@ -0,0 +1,623 @@
|
|||
import { app } from "electron";
|
||||
import { exec, execFile } from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
|
||||
//
|
||||
// ------------------ HELPERS ------------------
|
||||
//
|
||||
|
||||
// Promise wrapper for exec()
|
||||
function execPromise(cmd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, (err, stdout, stderr) => {
|
||||
if (err) return reject(stderr || err);
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Detect dev vs production
|
||||
function isDev() {
|
||||
return !app.isPackaged;
|
||||
}
|
||||
|
||||
//
|
||||
// ------------------ PATH RESOLVERS ------------------
|
||||
//
|
||||
|
||||
// WireGuard installer path (Windows only)
|
||||
// function getWireGuardInstallerPath(): string {
|
||||
// return isDev()
|
||||
// ? path.join(process.cwd(), "resources", "wireguard-install", "wireguard-installer.exe")
|
||||
// : path.join(process.resourcesPath, "wireguard-install", "wireguard-installer.exe");
|
||||
// }
|
||||
|
||||
function getWireGuardInstallerPath(): string {
|
||||
const devPath = path.join(process.cwd(), "resources", "wireguard-install", "wireguard-installer.exe");
|
||||
|
||||
// When running "npm run dev", use devPath ALWAYS
|
||||
if (!app.isPackaged) {
|
||||
console.log("DEV MODE Installer Path:", devPath);
|
||||
return devPath;
|
||||
}
|
||||
|
||||
// Production build path
|
||||
const prodPath = path.join(process.resourcesPath, "wireguard-install", "wireguard-installer.exe");
|
||||
console.log("PROD MODE Installer Path:", prodPath);
|
||||
return prodPath;
|
||||
}
|
||||
|
||||
|
||||
// OS-specific wg0.conf
|
||||
// function getConfigPath(): string {
|
||||
// const base = isDev()
|
||||
// ? path.join(process.cwd(), "resources", "wireguard")
|
||||
// : path.join(process.resourcesPath, "wireguard");
|
||||
|
||||
// if (process.platform === "win32") {
|
||||
// return path.join(base, "windows", "wg0.conf");
|
||||
// }
|
||||
|
||||
// if (process.platform === "darwin") {
|
||||
// return path.join(base, "macos", "wg0.conf");
|
||||
// }
|
||||
|
||||
// throw new Error("Unsupported OS");
|
||||
// }
|
||||
|
||||
// export async function getConfigPath(): Promise<string> {
|
||||
// if (process.platform !== "win32") {
|
||||
// throw new Error("Only Windows supported for dynamic WireGuard config");
|
||||
// }
|
||||
// console.log("Calling http://35.95.209.161/api/v1/vpn/connect")
|
||||
// // 1️⃣ Call API to get dynamic config
|
||||
// let response = null;
|
||||
// try {
|
||||
// response = await axios.post(
|
||||
// "http://35.95.209.161/api/v1/vpn/connect",
|
||||
// {},
|
||||
// { timeout: 15000 }
|
||||
// );
|
||||
// } catch (err: any) {
|
||||
// console.error("VPN API error:", {
|
||||
// message: err.message,
|
||||
// code: err.code,
|
||||
// response: err.response?.data,
|
||||
// });
|
||||
// throw err;
|
||||
// }
|
||||
// console.log("Called http://35.95.209.161/api/v1/vpn/connect", JSON.stringify(response.data));
|
||||
|
||||
// if (!response.data?.success || !response.data.clientConf) {
|
||||
// throw new Error("Invalid VPN config response");
|
||||
// }
|
||||
|
||||
// const clientConf: string = response.data.clientConf;
|
||||
|
||||
// // 2️⃣ Decide base directory (same idea as your existing logic)
|
||||
// const base = isDev()
|
||||
// ? path.join(process.cwd(), "resources", "wireguard", "windows")
|
||||
// : path.join(process.resourcesPath, "wireguard", "windows");
|
||||
|
||||
// // Ensure directory exists
|
||||
// fs.mkdirSync(base, { recursive: true });
|
||||
|
||||
// // 3️⃣ Create temp / runtime conf file
|
||||
// // IMPORTANT: name must stay stable (WireGuard service name depends on it)
|
||||
// const confPath = path.join(base, "wg0.conf");
|
||||
|
||||
// // 4️⃣ Write config to file
|
||||
// fs.writeFileSync(confPath, clientConf, {
|
||||
// encoding: "utf-8",
|
||||
// mode: 0o600, // secure permissions
|
||||
// });
|
||||
|
||||
// console.log("VPN config written to:", confPath);
|
||||
// // 5️⃣ Return path (same contract as before)
|
||||
// return confPath;
|
||||
// }
|
||||
|
||||
export async function getConfigPath(): Promise<string> {
|
||||
console.log("Calling API: http://35.95.209.161/api/v1/vpn/connect");
|
||||
|
||||
// Fetch dynamic config from server (works for both Windows & macOS)
|
||||
let response = null;
|
||||
try {
|
||||
response = await axios.post(
|
||||
"http://35.95.209.161/api/v1/vpn/connect",
|
||||
{},
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error("VPN API error:", {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
response: err.response?.data,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!response.data?.success || !response.data.clientConf) {
|
||||
throw new Error("Invalid VPN config response");
|
||||
}
|
||||
|
||||
const clientConf: string = response.data.clientConf;
|
||||
|
||||
// ---------- PLATFORM SPECIFIC OUTPUT DIRECTORY ----------
|
||||
let base = "";
|
||||
|
||||
if (process.platform === "win32") {
|
||||
base = isDev()
|
||||
? path.join(process.cwd(), "resources", "wireguard", "windows")
|
||||
: path.join(process.resourcesPath, "wireguard", "windows");
|
||||
}
|
||||
|
||||
// if (process.platform === "darwin") {
|
||||
// base = isDev()
|
||||
// ? path.join(process.cwd(), "resources", "wireguard", "macos")
|
||||
// : path.join(process.resourcesPath, "wireguard", "macos");
|
||||
// }
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const base = "/usr/local/etc/wireguard";
|
||||
fs.mkdirSync(base, { recursive: true });
|
||||
|
||||
const confPath = path.join(base, "wg0.conf");
|
||||
fs.writeFileSync(confPath, clientConf, { encoding: "utf8", mode: 0o600 });
|
||||
|
||||
return confPath;
|
||||
}
|
||||
|
||||
|
||||
if (!base) throw new Error("Unsupported OS");
|
||||
|
||||
fs.mkdirSync(base, { recursive: true });
|
||||
|
||||
const confPath = path.join(base, "wg0.conf");
|
||||
|
||||
fs.writeFileSync(confPath, clientConf, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
console.log("Dynamic VPN config saved:", confPath);
|
||||
return confPath;
|
||||
}
|
||||
|
||||
|
||||
// Detect installed WireGuard.exe on Windows
|
||||
function findWireGuardCLI(): string | null {
|
||||
const possiblePaths = [
|
||||
"C:\\Program Files\\WireGuard\\wireguard.exe",
|
||||
"C:\\Program Files (x86)\\WireGuard\\wireguard.exe"
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//
|
||||
// ------------------ WINDOWS INSTALL LOGIC ------------------
|
||||
//
|
||||
|
||||
// Install WireGuard silently
|
||||
async function ensureWireGuardInstalled(): Promise<string> {
|
||||
let cli = findWireGuardCLI();
|
||||
if (cli) return cli;
|
||||
|
||||
// Installer path
|
||||
const installer = getWireGuardInstallerPath();
|
||||
if (!fs.existsSync(installer)) {
|
||||
throw new Error("WireGuard installer not found in resources.");
|
||||
}
|
||||
|
||||
console.log("WireGuard not found. Installing silently...");
|
||||
await execPromise(`"${installer}" /quiet`);
|
||||
|
||||
cli = findWireGuardCLI();
|
||||
if (!cli) throw new Error("WireGuard installation failed.");
|
||||
|
||||
console.log("WireGuard installed successfully:", cli);
|
||||
return cli;
|
||||
}
|
||||
|
||||
//
|
||||
// ------------------ WINDOWS CONNECT/DISCONNECT ------------------
|
||||
//
|
||||
|
||||
// async function connectWindows(): Promise<string> {
|
||||
// const cli = await ensureWireGuardInstalled();
|
||||
// const conf = getConfigPath();
|
||||
|
||||
// // Disable GUI auto popup
|
||||
// await execPromise(
|
||||
// `reg add "HKCU\\Software\\WireGuard" /v SuppressTray /t REG_DWORD /d 1 /f`
|
||||
// );
|
||||
|
||||
// return new Promise((resolve, reject) => {
|
||||
// execFile(cli, ["/installtunnelservice", conf], (err) => {
|
||||
// if (err) return reject("Failed to connect VPN: " + err);
|
||||
// resolve("Windows VPN connected without GUI");
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// let wgRuntimeProcess: any = null;
|
||||
|
||||
// async function connectWindows(): Promise<string> {
|
||||
// const cli = await ensureWireGuardInstalled();
|
||||
// const conf = getConfigPath();
|
||||
|
||||
// return new Promise((resolve, reject) => {
|
||||
// wgRuntimeProcess = execFile(cli, ["/tunnelservice", conf], (err) => {
|
||||
// if (err) return reject("VPN connection error: " + err);
|
||||
// });
|
||||
|
||||
// // Wait a bit for interface to come up
|
||||
// setTimeout(() => resolve("Windows VPN connected silently"), 800);
|
||||
// });
|
||||
// }
|
||||
|
||||
//
|
||||
// ------------------ HEALTH + STATUS HELPERS ------------------
|
||||
//
|
||||
|
||||
// 1️⃣ Check if wg0 interface exists (works for mac + windows)
|
||||
async function isTunnelActive(): Promise<boolean> {
|
||||
console.log("🔍 Checking if VPN tunnel is active...");
|
||||
|
||||
try {
|
||||
// ---------------------- macOS ----------------------
|
||||
if (process.platform === "darwin") {
|
||||
console.log("🖥 Platform: macOS — detecting WireGuard via utun interface...");
|
||||
|
||||
const netOutput = await execPromise(`ifconfig`);
|
||||
console.log("📡 ifconfig output length:", netOutput.length);
|
||||
|
||||
const utunMatch = netOutput.match(/utun\d+/);
|
||||
console.log("🔎 utun interface detected:", utunMatch);
|
||||
|
||||
if (utunMatch) {
|
||||
console.log("✅ VPN tunnel ACTIVE (utun interface exists)");
|
||||
return true;
|
||||
} else {
|
||||
console.log("❌ VPN tunnel NOT active (no utun interface found)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Windows ----------------------
|
||||
if (process.platform === "win32") {
|
||||
console.log("🖥 Platform: Windows — detecting WireGuard via wg show...");
|
||||
|
||||
const wgOutput = await execPromise(`wg show 2>&1`);
|
||||
console.log("📡 wg show output length:", wgOutput.length);
|
||||
|
||||
const interfaceFound = wgOutput.includes("interface: wg0");
|
||||
console.log("🔎 wg0 interface detected:", interfaceFound);
|
||||
|
||||
if (interfaceFound) {
|
||||
console.log("✅ VPN tunnel ACTIVE (wg0 found)");
|
||||
return true;
|
||||
} else {
|
||||
console.log("❌ VPN tunnel NOT active (wg0 not found)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Unsupported OS ----------------------
|
||||
console.log("⚠️ Unsupported OS platform — cannot detect VPN state");
|
||||
return false;
|
||||
|
||||
} catch (err: any) {
|
||||
console.log("❌ isTunnelActive() failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2️⃣ Check endpoint health
|
||||
async function isTunnelHealthy(): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get("http://10.0.0.1/health", { timeout: 2000 });
|
||||
return !!response.data?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ Download config only if needed
|
||||
async function ensureConfig(platform: string): Promise<string> {
|
||||
return await getConfigPath(); // uses dynamic logic already
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function connectWindows(): Promise<string> {
|
||||
|
||||
try {
|
||||
const response = await axios.get('http://35.95.209.161')
|
||||
console.log("Response from test API:", response.data);
|
||||
} catch (error) {
|
||||
console.error("Error connecting to test API:", error);
|
||||
}
|
||||
|
||||
const cli = await ensureWireGuardInstalled();
|
||||
const conf = await getConfigPath();
|
||||
|
||||
// Disable GUI popup and silent mode (ignore errors)
|
||||
await execPromise(`reg add "HKCU\\Software\\WireGuard" /v HideTrayIcon /t REG_DWORD /d 1 /f`).catch(() => { });
|
||||
await execPromise(`reg add "HKCU\\Software\\WireGuard" /v SuppressTray /t REG_DWORD /d 1 /f`).catch(() => { });
|
||||
await execPromise(`reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v WireGuard /f`).catch(() => { });
|
||||
|
||||
// Start background service
|
||||
await execPromise(`sc start WireGuardManager`).catch(() => { });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cli, ["/installtunnelservice", conf], (err) => {
|
||||
if (err) return reject("VPN connection failed: " + err);
|
||||
resolve("Windows VPN connected silently");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// function disconnectWindows(): Promise<string> {
|
||||
// const cli = findWireGuardCLI();
|
||||
// if (!cli) return Promise.reject("WireGuard CLI not found");
|
||||
|
||||
// return execPromise(`"${cli}" /uninstalltunnelservice wg0`);
|
||||
// }
|
||||
|
||||
async function disconnectWindows(): Promise<string> {
|
||||
const cli = findWireGuardCLI();
|
||||
if (!cli) throw new Error("WireGuard CLI not found");
|
||||
|
||||
// 1. Remove the installed tunnel service
|
||||
await execPromise(`"${cli}" /uninstalltunnelservice wg0`);
|
||||
|
||||
// 2. Ensure background service is still running (optional clean-up)
|
||||
// Windows sometimes marks the service as "stopped" after uninstall
|
||||
await execPromise(`sc stop WireGuardTunnel$wg0`).catch(() => { });
|
||||
await execPromise(`sc stop WireGuardManager`).catch(() => { });
|
||||
|
||||
return "Windows VPN disconnected";
|
||||
}
|
||||
|
||||
|
||||
async function ensureHomebrew(): Promise<void> {
|
||||
try {
|
||||
await execPromise("brew --version");
|
||||
return; // brew already installed
|
||||
} catch (_) {
|
||||
console.log("Homebrew missing. Installing...");
|
||||
|
||||
await execPromise(
|
||||
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
);
|
||||
|
||||
// Update shell env — macOS uses zprofile for system shells
|
||||
await execPromise(`echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWireGuardMac(): Promise<void> {
|
||||
try {
|
||||
await execPromise("which wg");
|
||||
return; // already installed
|
||||
} catch (_) {
|
||||
console.log("Installing Homebrew + wireguard-tools...");
|
||||
|
||||
// Install Homebrew if missing
|
||||
try {
|
||||
await execPromise("brew --version");
|
||||
} catch {
|
||||
console.log("Homebrew missing. Installing...");
|
||||
await execPromise(
|
||||
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
);
|
||||
|
||||
// Load Homebrew env for next commands
|
||||
await execPromise(`echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile`);
|
||||
await execPromise(`eval "$(/opt/homebrew/bin/brew shellenv)"`);
|
||||
}
|
||||
|
||||
// Install CLI WireGuard tools
|
||||
await execPromise("brew install wireguard-tools");
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// --------------- MAC CONNECT/DISCONNECT ---------------
|
||||
//
|
||||
|
||||
// async function connectMac(): Promise<string> {
|
||||
// const conf = getConfigPath();
|
||||
|
||||
// // Wrap command in login shell so Homebrew paths are available
|
||||
// const cmd = `/bin/bash -l -c "/opt/homebrew/bin/wg-quick up '${conf}'"`;
|
||||
|
||||
// const script = `
|
||||
// do shell script "${cmd}" with administrator privileges
|
||||
// `;
|
||||
// const escapedScript = script.replace(/\n/g, " ");
|
||||
|
||||
// return execPromise(`osascript -e '${escapedScript}'`)
|
||||
// .then(() => "macOS VPN connected (wg-quick)")
|
||||
// .catch(err => {
|
||||
// throw new Error("macOS VPN connect failed: " + err);
|
||||
// });
|
||||
// }
|
||||
|
||||
// async function connectMac(): Promise<string> {
|
||||
// try {
|
||||
// const conf = await getConfigPath();
|
||||
// console.log("Configuration Path:", conf); // Debug the path
|
||||
|
||||
// const cmd = `/bin/bash -l -c "/opt/homebrew/bin/wg-quick up '${conf}'"`;
|
||||
|
||||
// const script = `
|
||||
// do shell script "${cmd}" with administrator privileges
|
||||
// `;
|
||||
// const escapedScript = script.replace(/\n/g, " ");
|
||||
|
||||
// await execPromise(`osascript -e '${escapedScript}'`);
|
||||
|
||||
// return "macOS VPN connected (wg-quick)";
|
||||
// } catch (err) {
|
||||
// console.error("macOS VPN connect failed:", err);
|
||||
// throw new Error("macOS VPN connect failed: " + err);
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
// async function connectMac(): Promise<string> {
|
||||
// await ensureWireGuardMac();
|
||||
|
||||
// const conf = await getConfigPath();
|
||||
|
||||
// const cmd = `/bin/bash -c "sudo /opt/homebrew/bin/wg-quick up '${conf}'"`;
|
||||
|
||||
// try {
|
||||
// await execPromise(cmd);
|
||||
// return "macOS VPN connected";
|
||||
// } catch (err: any) {
|
||||
// throw new Error("macOS VPN connect failed: " + err.message);
|
||||
// }
|
||||
// }
|
||||
async function connectMac(): Promise<string> {
|
||||
await ensureWireGuardMac();
|
||||
|
||||
const conf = await getConfigPath();
|
||||
|
||||
// Detect the correct wg-quick binary
|
||||
let wgPath = (await execPromise(`which wg-quick`)).trim();
|
||||
|
||||
const cmd = `do shell script "${wgPath} up '${conf}'" with administrator privileges`;
|
||||
|
||||
try {
|
||||
const result = await execPromise(`osascript -e '${cmd}'`);
|
||||
console.log("macOS wg-quick result:", result);
|
||||
return "macOS VPN connected";
|
||||
} catch (err: any) {
|
||||
console.error("macOS VPN connect failed:", err);
|
||||
throw new Error("macOS VPN connect failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// function disconnectMac(): Promise<string> {
|
||||
// const cmd = `/bin/bash -l -c "/opt/homebrew/bin/wg-quick down wg0"`;
|
||||
|
||||
// const script = `
|
||||
// do shell script "${cmd}" with administrator privileges
|
||||
// `;
|
||||
// const escapedScript = script.replace(/\n/g, " ");
|
||||
|
||||
// return execPromise(`osascript -e '${escapedScript}'`)
|
||||
// .then(() => "macOS VPN disconnected")
|
||||
// .catch(err => {
|
||||
// throw new Error("macOS VPN disconnect failed: " + err);
|
||||
// });
|
||||
// }
|
||||
|
||||
// function disconnectMac(): Promise<string> {
|
||||
// const cmd = `/bin/bash -c "sudo /opt/homebrew/bin/wg-quick down wg0"`;
|
||||
|
||||
// return execPromise(cmd)
|
||||
// .then(() => "macOS VPN disconnected")
|
||||
// .catch(err => {
|
||||
// throw new Error("macOS VPN disconnect failed: " + err.message);
|
||||
// });
|
||||
// }
|
||||
|
||||
|
||||
|
||||
async function disconnectMac(): Promise<string> {
|
||||
let wgPath = (await execPromise(`which wg-quick`)).trim();
|
||||
|
||||
const cmd = `do shell script "${wgPath} down wg0" with administrator privileges`;
|
||||
|
||||
try {
|
||||
await execPromise(`osascript -e '${cmd}'`);
|
||||
return "macOS VPN disconnected";
|
||||
} catch (err: any) {
|
||||
throw new Error("macOS VPN disconnect failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//
|
||||
// ------------------ PUBLIC API ------------------
|
||||
//
|
||||
|
||||
export async function startVPN(): Promise<string> {
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform !== "win32" && platform !== "darwin") {
|
||||
throw new Error("Unsupported OS");
|
||||
}
|
||||
|
||||
// STEP 1: tunnel active?
|
||||
console.log("Start VPN Called for pltf :", platform)
|
||||
const active = await isTunnelActive();
|
||||
console.log("VPN Active Status :", active)
|
||||
|
||||
if (active) {
|
||||
console.log("VPN already active — checking health...");
|
||||
|
||||
// STEP 2: healthy?
|
||||
if (await isTunnelHealthy()) {
|
||||
console.log("VPN healthy — skip reconnect");
|
||||
return "VPN already connected and healthy";
|
||||
}
|
||||
|
||||
console.log("VPN active but unhealthy — reconnecting...");
|
||||
await stopVPN(); // gracefully disconnect
|
||||
}
|
||||
|
||||
// STEP 3: ensure config before connect
|
||||
await ensureConfig(platform);
|
||||
|
||||
// STEP 4: connect
|
||||
if (platform === "win32") {
|
||||
return connectWindows();
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
return connectMac();
|
||||
}
|
||||
|
||||
// STEP 5: unreachable fallback to satisfy TS
|
||||
throw new Error("Unexpected platform state");
|
||||
}
|
||||
|
||||
|
||||
export async function stopVPN(): Promise<string> {
|
||||
const platform = os.platform();
|
||||
|
||||
try {
|
||||
if (platform === "win32") return await disconnectWindows();
|
||||
if (platform === "darwin") return await disconnectMac();
|
||||
} catch {
|
||||
return "VPN not running";
|
||||
}
|
||||
|
||||
throw new Error("Unsupported OS");
|
||||
}
|
||||
|
||||
143
src/ui/App.tsx
143
src/ui/App.tsx
|
|
@ -1,56 +1,131 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
|
||||
// import React, { useEffect } from "react";
|
||||
// import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
|
||||
// import { useDispatch, useSelector } from "react-redux";
|
||||
// import { AppDispatch, RootState } from "./store";
|
||||
// import { loadUserFromStorage } from "./store/userSlice";
|
||||
|
||||
// import Login from "./pages/Login";
|
||||
// import Dashboard from "./pages/Dashboard";
|
||||
// import Projects from "./pages/Projects/Projects";
|
||||
// import DashboardLayout from "./layout/DashboardLayout";
|
||||
// import ProtectedRoute from "./routes/ProtectedRoute";
|
||||
// import PublicRoute from "./routes/PublicRoute";
|
||||
// import { ThemeToggle } from "./helper/ThemeToggle";
|
||||
// import LeaveAndAdjustments from "./pages/LeaveAndAdjustments/LeaveAndAdjustments";
|
||||
// import Timesheets from "./pages/Timesheets/Timesheets";
|
||||
// import Settings from "./pages/Settings/Settings";
|
||||
// import HelpSupport from "./pages/HelpSupport/HelpSupport";
|
||||
|
||||
// const App: React.FC = () => {
|
||||
// const theme = ThemeToggle();
|
||||
// const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
// // ✅ Get user directly from Redux
|
||||
// const user = useSelector((state: RootState) => state.user.user);
|
||||
|
||||
// useEffect(() => {
|
||||
// document.documentElement.setAttribute("data-theme", theme);
|
||||
// }, [theme]);
|
||||
|
||||
// // ✅ Load user from localStorage into Redux when app starts
|
||||
// useEffect(() => {
|
||||
// dispatch(loadUserFromStorage());
|
||||
// }, [dispatch]);
|
||||
|
||||
// console.log("App.tsx → user:", user);
|
||||
|
||||
// return (
|
||||
// <BrowserRouter>
|
||||
// <Routes>
|
||||
// {/* Public Route */}
|
||||
// <Route
|
||||
// path="/login"
|
||||
// element={
|
||||
// <PublicRoute user={user}>
|
||||
// <Login />
|
||||
// </PublicRoute>
|
||||
// }
|
||||
// />
|
||||
|
||||
// {/* Protected Routes */}
|
||||
// <Route
|
||||
// path="/dashboard"
|
||||
// element={
|
||||
// <ProtectedRoute user={user}>
|
||||
// <DashboardLayout />
|
||||
// </ProtectedRoute>
|
||||
// }
|
||||
// >
|
||||
// <Route index element={<Dashboard />} />
|
||||
// <Route path="projects" element={<Projects />} />
|
||||
// <Route path="leave-adjustments" element={<LeaveAndAdjustments />} />
|
||||
// <Route path="timesheet" element={<Timesheets />} />
|
||||
// <Route path="settings" element={<Settings />} />
|
||||
// <Route path="help-support" element={<HelpSupport />} />
|
||||
// </Route>
|
||||
|
||||
// {/* Default Redirect */}
|
||||
// <Route
|
||||
// path="*"
|
||||
// element={<Navigate to={user ? "/dashboard" : "/login"} replace />}
|
||||
// />
|
||||
// </Routes>
|
||||
// </BrowserRouter>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default App;
|
||||
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { HashRouter as Router, Route, Routes, Navigate } from "react-router-dom";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AppDispatch, RootState } from "./store";
|
||||
import { loadUserFromStorage } from "./store/userSlice";
|
||||
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Projects from "./pages/Projects/Projects";
|
||||
import DashboardLayout from "./layout/DashboardLayout";
|
||||
import ProtectedRoute from "./routes/ProtectedRoute"
|
||||
import ProtectedRoute from "./routes/ProtectedRoute";
|
||||
import PublicRoute from "./routes/PublicRoute";
|
||||
import { ThemeToggle } from "./helper/ThemeToggle"
|
||||
import LeaveAndAdjustments from "./pages/LeaveAndAdjustments/LeaveAndAdjustments";
|
||||
import Timesheets from "./pages/Timesheets/Timesheets";
|
||||
import Settings from "./pages/Settings/Settings";
|
||||
import HelpSupport from "./pages/HelpSupport/HelpSupport";
|
||||
|
||||
interface User {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
import Vpn from "./pages/Vpn/Vpn";
|
||||
import Schedule from "./pages/Schedule/Schedule";
|
||||
import PaysSlips from "./pages/PaySlips/PaysSlips";
|
||||
import ApplyLeaveAndAdjustmentsTime from "./pages/LeaveAndAdjustments/ApplyLeaveAndAdjustmentsTime";
|
||||
import VpnGuard from "./pages/Vpn/VpnGuard";
|
||||
|
||||
|
||||
const App: React.FC = () => {
|
||||
const theme = ThemeToggle();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
// ✅ Get user directly from Redux
|
||||
const user = useSelector((state: RootState) => state.user.user);
|
||||
|
||||
|
||||
|
||||
// Apply theme
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
// Load user from localStorage
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch (err) {
|
||||
console.error("Invalid user in localStorage", err);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
dispatch(loadUserFromStorage());
|
||||
}, [dispatch]);
|
||||
|
||||
console.log("App.tsx → user:", user);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public Route */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute user={user}>
|
||||
<Login setUser={setUser} />
|
||||
<VpnGuard>
|
||||
<Login />
|
||||
</VpnGuard>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
|
@ -60,16 +135,22 @@ const App: React.FC = () => {
|
|||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute user={user}>
|
||||
<DashboardLayout user={user} setUser={setUser} />
|
||||
{/* <VpnGuard> */}
|
||||
<DashboardLayout />
|
||||
{/* </VpnGuard> */}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="leave-adjustments" element={<LeaveAndAdjustments />} />
|
||||
<Route path="leave-adjustments/apply" element={<ApplyLeaveAndAdjustmentsTime />} />
|
||||
<Route path="timesheet" element={<Timesheets />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="vpn" element={<Vpn />} />
|
||||
<Route path="help-support" element={<HelpSupport />} />
|
||||
<Route path="schedule" element={<Schedule />} />
|
||||
<Route path="pays-slips" element={<PaysSlips />} />
|
||||
</Route>
|
||||
|
||||
{/* Default Redirect */}
|
||||
|
|
@ -78,7 +159,7 @@ const App: React.FC = () => {
|
|||
element={<Navigate to={user ? "/dashboard" : "/login"} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,30 @@
|
|||
// src/hooks/ThemeToggle.js
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const ThemeToggle = () => {
|
||||
export const useTheme = () => {
|
||||
const [theme, setTheme] = useState("light");
|
||||
|
||||
useEffect(() => {
|
||||
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
// Initial check
|
||||
setTheme(darkQuery.matches ? "dark" : "light");
|
||||
|
||||
// Listen for changes
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const handleChange = (e:any) => {
|
||||
setTheme(e.matches ? "dark" : "light");
|
||||
};
|
||||
|
||||
darkQuery.addEventListener("change", handleChange);
|
||||
darkQuery.addEventListener?.("change", handleChange);
|
||||
darkQuery.addListener?.(handleChange); // For older Electron
|
||||
|
||||
return () => darkQuery.removeEventListener("change", handleChange);
|
||||
return () => {
|
||||
darkQuery.removeEventListener?.("change", handleChange);
|
||||
darkQuery.removeListener?.(handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
// Apply theme to <html>
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return { theme, setTheme };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// contexts/ToastContext.tsx
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type?: Toast['type']) => void;
|
||||
hideToast: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = (message: string, type: Toast['type'] = 'success') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
|
||||
// Auto remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
hideToast(id);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const hideToast = (id: number) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, hideToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} hideToast={hideToast} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const ToastContainer: React.FC<{
|
||||
toasts: Toast[];
|
||||
hideToast: (id: number) => void;
|
||||
}> = ({ toasts, hideToast }) => {
|
||||
const getTypeStyles = (type: Toast['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-success text-success-content';
|
||||
case 'error':
|
||||
return 'bg-error text-error-content';
|
||||
case 'warning':
|
||||
return 'bg-warning text-warning-content';
|
||||
case 'info':
|
||||
return 'bg-info text-info-content';
|
||||
default:
|
||||
return 'bg-neutral text-neutral-content';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="toast toast-center toast-top z-2000">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="w-full max-w-md px-2 mb-2"
|
||||
>
|
||||
<div
|
||||
className={`alert ${getTypeStyles(toast.type)} shadow-lg flex items-start`}
|
||||
onClick={() => hideToast(toast.id)}
|
||||
>
|
||||
<div className="flex-grow min-w-0">
|
||||
<p className="text-sm break-words whitespace-pre-wrap">
|
||||
{toast.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs flex-shrink-0 ml-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
hideToast(toast.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
// hooks/useActiveAppTracker.tsx
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "../store";
|
||||
import { WindowInfo } from "../store/windowSlice";
|
||||
import {
|
||||
initializeProjectLogs,
|
||||
updateWindowLog,
|
||||
clearProjectLogs,
|
||||
debugLogsState,
|
||||
} from "../store/logsSlice";
|
||||
|
||||
interface DailyHours {
|
||||
todayTotal: string;
|
||||
dailyLimit: string;
|
||||
remaining: string;
|
||||
}
|
||||
|
||||
export const useActiveAppTracker = (
|
||||
isTracking: boolean,
|
||||
activeProjectLogId: string | null,
|
||||
projectId: string | null,
|
||||
projectName: string | null,
|
||||
onLimitExceeded?: () => void
|
||||
) => {
|
||||
const dispatch = useDispatch();
|
||||
const { logsByProject } = useSelector((state: RootState) => state.logs);
|
||||
const lastActiveRef = useRef<WindowInfo | null>(null);
|
||||
const lastTitleRef = useRef<string | null>(null);
|
||||
const lastAppNameRef = useRef<string | null>(null);
|
||||
const intervalIdRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const limitCheckedRef = useRef(false);
|
||||
const isStoppingRef = useRef(false);
|
||||
const checkCountRef = useRef(0);
|
||||
|
||||
// Initialize logs when tracking starts
|
||||
useEffect(() => {
|
||||
if (isTracking && activeProjectLogId && projectId) {
|
||||
// Initialize fresh logs for this tracking session
|
||||
dispatch(
|
||||
initializeProjectLogs({
|
||||
projectId,
|
||||
logId: activeProjectLogId,
|
||||
})
|
||||
);
|
||||
console.log(` Logs initialized for project: ${projectName} (${projectId})`);
|
||||
|
||||
// Reset refs
|
||||
lastActiveRef.current = null;
|
||||
lastTitleRef.current = null;
|
||||
lastAppNameRef.current = null;
|
||||
checkCountRef.current = 0;
|
||||
isStoppingRef.current = false;
|
||||
}
|
||||
}, [isTracking, activeProjectLogId, projectId, projectName, dispatch]);
|
||||
|
||||
// Stop tracking intervals
|
||||
const stopIntervals = useCallback(() => {
|
||||
if (intervalIdRef.current) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
intervalIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debug function to check logs state
|
||||
const debugCurrentLogs = useCallback(() => {
|
||||
if (projectId) {
|
||||
dispatch(debugLogsState({ projectId }));
|
||||
}
|
||||
}, [projectId, dispatch]);
|
||||
|
||||
// Finalize and send all logs when stopping tracking
|
||||
// hooks/useActiveAppTracker.tsx - finalizeAndSendLogs function
|
||||
const finalizeAndSendLogs = useCallback(
|
||||
async (vaId: string): Promise<boolean> => {
|
||||
console.log(" FINALIZING AND SENDING ALL LOGS");
|
||||
|
||||
// Set stopping flag
|
||||
isStoppingRef.current = true;
|
||||
|
||||
// Stop intervals immediately
|
||||
stopIntervals();
|
||||
|
||||
if (!activeProjectLogId || !projectId) {
|
||||
console.log("⚠️ Skipping — no active project");
|
||||
return false;
|
||||
}
|
||||
|
||||
const projectLogs = logsByProject[projectId];
|
||||
if (!projectLogs || projectLogs.logs.length === 0) {
|
||||
console.log("ℹ️ No logs found for project", projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a DEEP copy of the logs
|
||||
const allLogs = JSON.parse(JSON.stringify(projectLogs.logs));
|
||||
|
||||
// Filter out logs with very short duration (less than 1000ms = 1 second)
|
||||
const validLogs = allLogs.filter((log:any) => {
|
||||
return log.totalTime >= 1000; // Minimum 1 second (1000ms)
|
||||
});
|
||||
|
||||
console.log(`📊 Total logs: ${allLogs.length}, Valid logs (≥1000ms): ${validLogs.length}`);
|
||||
|
||||
if (validLogs.length === 0) {
|
||||
console.log("ℹ️ No valid logs to send");
|
||||
dispatch(clearProjectLogs({ projectId }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prepare payload with all valid logs - send directly as stored
|
||||
const payload = {
|
||||
vaId: vaId || "",
|
||||
projectId,
|
||||
logId: activeProjectLogId,
|
||||
logs: validLogs, // Already formatted correctly
|
||||
};
|
||||
|
||||
console.log("📤 Payload to send:", payload);
|
||||
// console.log("📊 Sending logs summary:", {
|
||||
// projectId,
|
||||
// projectName,
|
||||
// logCount: validLogs.length,
|
||||
// totalTimeMs: validLogs.reduce((sum, log) => sum + log.totalTime, 0),
|
||||
// totalTimeSec: validLogs.reduce((sum, log) => sum + Math.round(log.totalTime / 1000), 0),
|
||||
// sampleLog: validLogs[0] ? {
|
||||
// title: validLogs[0].title.substring(0, 40),
|
||||
// from: validLogs[0].from,
|
||||
// to: validLogs[0].to,
|
||||
// durationMs: validLogs[0].totalTime,
|
||||
// } : null,
|
||||
// });
|
||||
|
||||
let res = await window.electron.trackingTabsOpeningClosing(payload);
|
||||
console.log("Tracking response:", res);
|
||||
|
||||
dispatch(clearProjectLogs({ projectId }));
|
||||
|
||||
// Cleanup refs
|
||||
lastActiveRef.current = null;
|
||||
lastTitleRef.current = null;
|
||||
lastAppNameRef.current = null;
|
||||
limitCheckedRef.current = false;
|
||||
|
||||
console.log("✅ Logs sent and cleared successfully");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("❌ Error sending logs:", error);
|
||||
console.log("=".repeat(60));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
activeProjectLogId,
|
||||
projectId,
|
||||
logsByProject,
|
||||
stopIntervals,
|
||||
dispatch,
|
||||
]
|
||||
);
|
||||
|
||||
// Main tracking logic - runs every 5 seconds
|
||||
useEffect(() => {
|
||||
if (!isTracking || !projectId || !activeProjectLogId) {
|
||||
console.log("⏸️ Tracking inactive — stopping interval");
|
||||
stopIntervals();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🚀 Starting tracker for: ${projectName}`);
|
||||
|
||||
const checkActiveWindow = async () => {
|
||||
if (isStoppingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const details = (await window.electron.getActiveAppDetails()) as WindowInfo;
|
||||
|
||||
if (!details || !details.id || !details.owner?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTitle = details.title;
|
||||
const currentAppName = details.owner.name;
|
||||
|
||||
checkCountRef.current += 1;
|
||||
|
||||
// Update Redux with current window activity
|
||||
dispatch(
|
||||
updateWindowLog({
|
||||
projectId,
|
||||
title: currentTitle,
|
||||
appName: currentAppName,
|
||||
url: null,
|
||||
})
|
||||
);
|
||||
|
||||
// Update refs
|
||||
lastActiveRef.current = details;
|
||||
lastTitleRef.current = currentTitle;
|
||||
lastAppNameRef.current = currentAppName;
|
||||
|
||||
// Debug every 10 checks (every 50 seconds)
|
||||
if (checkCountRef.current % 10 === 0) {
|
||||
debugCurrentLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to check active window:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkActiveWindow();
|
||||
|
||||
// Set up interval (every 5 seconds)
|
||||
intervalIdRef.current = setInterval(checkActiveWindow, 5000);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
console.log("🧹 Cleaning up tracker");
|
||||
stopIntervals();
|
||||
};
|
||||
}, [
|
||||
isTracking,
|
||||
activeProjectLogId,
|
||||
projectId,
|
||||
projectName,
|
||||
stopIntervals,
|
||||
dispatch,
|
||||
debugCurrentLogs,
|
||||
]);
|
||||
|
||||
return {
|
||||
finalizeAndSendLogs,
|
||||
stopIntervals,
|
||||
setIsStopping: (value: boolean) => {
|
||||
isStoppingRef.current = value;
|
||||
},
|
||||
debugCurrentLogs,
|
||||
};
|
||||
};
|
||||
|
|
@ -22,6 +22,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--bc) / 0.3); /* base-content/30 */
|
||||
border-radius: 999px;
|
||||
}
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,9 +2,44 @@ import React from 'react'
|
|||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./store";
|
||||
import { ToastProvider } from './helper/ToastContext';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__allowClose__: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// renderer only
|
||||
window.addEventListener("online", (e) => {
|
||||
console.log("Network online (ignored)");
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener("offline", (e) => {
|
||||
console.log("Network offline (ignored)");
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Extra safety
|
||||
window.__allowClose__ = false;
|
||||
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (!window.__allowClose__) {
|
||||
e.returnValue = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</ToastProvider>
|
||||
</Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import React from "react";
|
||||
|
||||
interface LogoutModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const LogoutModal: React.FC<LogoutModalProps> = ({ open, onClose, onConfirm }) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<dialog open className="modal">
|
||||
<div className="modal-box max-w-[75vw]">
|
||||
<h3 className="font-bold text-sm text-error">Confirm Logout</h3>
|
||||
|
||||
<p className="py-2 text-xs font-medium">
|
||||
Are you sure you want to logout? You will need to login again to access your dashboard.
|
||||
</p>
|
||||
|
||||
<div className="modal-action">
|
||||
|
||||
{/* Cancel */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-xs"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoutModal;
|
||||
|
|
@ -1,12 +1,22 @@
|
|||
import React from "react";
|
||||
import { Clock, TrendingUp, Play, Square, ProjectorIcon, Pause } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Clock, TrendingUp, Play, Square, ProjectorIcon, Pause, RefreshCw } from "lucide-react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import NoData from "./NoData";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { initializeProjectTracking, setProjectsDataSlice, setProjectSummarySlice, setSelectedProjectSlice, updateProjectTracking } from "../store/projectSlice";
|
||||
import { setError, setLoading, setProfile } from "../store/profileSlice";
|
||||
import { getFormattedRemainingTime, isProjectLimitReached } from '../utils/projectLimits';
|
||||
import { parseTimeStringToSeconds } from "../utils/timeParser";
|
||||
|
||||
// ----- Interfaces -----
|
||||
// Updated Project interface to match JSON structure
|
||||
interface Project {
|
||||
id: number;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
clientName: string;
|
||||
activityStatus: "ACTIVE" | "AWAY";
|
||||
totalWorkTime: string;
|
||||
totalWorkMs: number;
|
||||
totalLogs: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ProjectTrackingData {
|
||||
|
|
@ -19,7 +29,7 @@ interface ProjectTrackingData {
|
|||
|
||||
interface TrackingState {
|
||||
selectedProject: Project | null;
|
||||
projectTrackingMap: Map<number, ProjectTrackingData>;
|
||||
// projectTrackingMap: Map<string, ProjectTrackingData>;
|
||||
isTracking: boolean;
|
||||
isPaused: boolean;
|
||||
elapsedTime: number;
|
||||
|
|
@ -28,19 +38,23 @@ interface TrackingState {
|
|||
handleStartTracking: (project: Project) => void;
|
||||
handlePauseResume: () => void;
|
||||
handleStopTracking: () => void;
|
||||
handleProjectSelect: (id: number) => void;
|
||||
handleProjectSelect: (projectId: string) => void;
|
||||
projectsData: Project[];
|
||||
loadingStart: boolean,
|
||||
loadingStop: boolean,
|
||||
isStopping: boolean
|
||||
}
|
||||
|
||||
// ----- Helper -----
|
||||
// Helper function
|
||||
const formatTime = (seconds: number) => {
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${hrs}h ${mins}m ${secs}s`;
|
||||
// return `${hrs}h ${mins}m `;
|
||||
};
|
||||
|
||||
// ----- Widget -----
|
||||
// Widget Component
|
||||
const Widget: React.FC<{
|
||||
title: string;
|
||||
value: string | number;
|
||||
|
|
@ -71,11 +85,9 @@ const Widget: React.FC<{
|
|||
</div>
|
||||
);
|
||||
|
||||
// ----- Dashboard -----
|
||||
// Dashboard Component
|
||||
const Dashboard: React.FC = () => {
|
||||
const {
|
||||
selectedProject,
|
||||
projectTrackingMap,
|
||||
isTracking,
|
||||
isPaused,
|
||||
elapsedTime,
|
||||
|
|
@ -84,131 +96,278 @@ const Dashboard: React.FC = () => {
|
|||
handlePauseResume,
|
||||
handleStopTracking,
|
||||
handleProjectSelect,
|
||||
projectsData,
|
||||
loadingStart,
|
||||
loadingStop,
|
||||
isStopping
|
||||
} = useOutletContext<TrackingState>();
|
||||
|
||||
const projectsData = useSelector((state: any) => state.projects.projectsData);
|
||||
const selectedProject = useSelector((state: any) => state.projects.selectedProject);
|
||||
const projectSummary = useSelector((state: any) => state.projects.projectSummary);
|
||||
const projectTrackingMap = useSelector((state: any) => state.projects.projectTrackingMap);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const isLimitReached = selectedProject ? isProjectLimitReached(selectedProject) : false;
|
||||
|
||||
console.log("Line 111", projectsData, selectedProject, projectTrackingMap)
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
const res = await window.electron.getProfile();
|
||||
console.log("Profile", res)
|
||||
if (res.success && res.data) {
|
||||
dispatch(setProfile(res.data));
|
||||
} else {
|
||||
dispatch(setError("Failed to fetch profile"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
dispatch(setError(err.message));
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
console.log("📡 Fetching projects...Dashboard");
|
||||
const res = await window.electron.getProjectList();
|
||||
console.log("Project list response in dashboard: ", res.data?.[0]);
|
||||
|
||||
if (res?.success && Array.isArray(res.data)) {
|
||||
// Store data in redux store
|
||||
dispatch(setProjectsDataSlice(res.data));
|
||||
|
||||
dispatch(
|
||||
setProjectSummarySlice({
|
||||
totalProjects: res.totalProjects || 0,
|
||||
activeProjects: res.activeProjects || 0,
|
||||
awayProjects: res.awayProjects || 0,
|
||||
totalWorkTime: res.totalWorkTime || "0h 0m 0s",
|
||||
totalWorkMs: res.totalWorkMs || 0,
|
||||
vaName: res.vaName || "",
|
||||
vaId: res.vaId || "",
|
||||
})
|
||||
);
|
||||
|
||||
// FIX: Check if we have any projects
|
||||
if (res.data.length === 0) {
|
||||
// No projects available, clear selected project
|
||||
dispatch(setSelectedProjectSlice(null));
|
||||
// fetchProfile();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have a selected project that's being tracked
|
||||
const currentlySelected = selectedProject;
|
||||
const currentlyTracking = isTracking;
|
||||
|
||||
// Find if the currently selected project still exists in the fresh data
|
||||
let projectToSelect = null;
|
||||
|
||||
if (currentlySelected && currentlyTracking) {
|
||||
// We're tracking a project - try to preserve it
|
||||
const existingProject = res.data.find(
|
||||
(p: Project) => p.projectId === currentlySelected.projectId
|
||||
);
|
||||
|
||||
if (existingProject) {
|
||||
projectToSelect = existingProject;
|
||||
}
|
||||
}
|
||||
|
||||
// If no project is selected OR tracked project no longer exists, auto-select first project
|
||||
if (!projectToSelect && res.data.length > 0) {
|
||||
projectToSelect = res.data[0];
|
||||
}
|
||||
|
||||
if (projectToSelect) {
|
||||
console.log("Line 158", projectToSelect);
|
||||
// Select the project
|
||||
dispatch(setSelectedProjectSlice(projectToSelect));
|
||||
|
||||
// Only initialize tracking if it doesn't exist already
|
||||
const trackingExists = projectTrackingMap[projectToSelect.projectId];
|
||||
if (!trackingExists) {
|
||||
const initialElapsed = parseTimeStringToSeconds(projectToSelect.totalWorkTime);
|
||||
dispatch(
|
||||
initializeProjectTracking({
|
||||
projectId: projectToSelect.projectId,
|
||||
initialTime: initialElapsed,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Update project data but preserve tracking state
|
||||
dispatch(
|
||||
updateProjectTracking({
|
||||
projectId: projectToSelect.projectId,
|
||||
data: {
|
||||
...trackingExists,
|
||||
// Update elapsed time from server if needed
|
||||
elapsedTime: parseTimeStringToSeconds(projectToSelect.totalWorkTime),
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProfile();
|
||||
} else {
|
||||
console.warn("⚠️ Unexpected response:", res);
|
||||
// FIX: Also clear selected project on unexpected response
|
||||
dispatch(setSelectedProjectSlice(null));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Error fetching projects:", err);
|
||||
// FIX: Clear selected project on error
|
||||
dispatch(setSelectedProjectSlice(null));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
fetchProfile()
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
// setIsRefreshing(true);
|
||||
await fetchProjects();
|
||||
// setTimeout(() => setIsRefreshing(false), 500); // Small delay for visual feedback
|
||||
};
|
||||
|
||||
const selectedData = selectedProject
|
||||
? projectTrackingMap.get(selectedProject.id)
|
||||
? projectTrackingMap[selectedProject.projectId]
|
||||
: null;
|
||||
|
||||
// FIX: Get the correct time to display
|
||||
const getDisplayTime = () => {
|
||||
if (!selectedProject) return 0;
|
||||
|
||||
// If tracking is active, use real-time elapsedTime
|
||||
if (selectedData?.isTracking) {
|
||||
return selectedData.elapsedTime || 0;
|
||||
}
|
||||
|
||||
// If not tracking, use server time from selectedProject
|
||||
return parseTimeStringToSeconds(selectedProject.totalWorkTime);
|
||||
};
|
||||
|
||||
const displayTime = getDisplayTime();
|
||||
|
||||
const formatWorkTime = (time: string) => {
|
||||
if (!time) return "0h 0m";
|
||||
|
||||
// Example input: "0h 2m 29s"
|
||||
const parts = time.split(" ");
|
||||
|
||||
// Always return first two parts: ["0h", "2m"]
|
||||
return parts.slice(0, 2).join(" ");
|
||||
};
|
||||
|
||||
const handleProjectSelectLocal = (projectId: string) => {
|
||||
const project = projectsData.find((p: any) => p.projectId === projectId);
|
||||
if (!project) return;
|
||||
|
||||
dispatch(setSelectedProjectSlice(project));
|
||||
|
||||
const existingData = projectTrackingMap[projectId];
|
||||
|
||||
if (!existingData) {
|
||||
const initialElapsed = parseTimeStringToSeconds(project.totalWorkTime);
|
||||
dispatch(initializeProjectTracking({
|
||||
projectId,
|
||||
initialTime: initialElapsed
|
||||
}));
|
||||
} else {
|
||||
// Restore saved tracking state
|
||||
dispatch(updateProjectTracking({
|
||||
projectId,
|
||||
data: existingData
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto no-scrollbar">
|
||||
<main className="flex-1 overflow-hidden no-scrollbar ">
|
||||
{/* Header */}
|
||||
<div className="mb-2">
|
||||
<h1 className="text-md font-bold">Dashboard</h1>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await window.electron.uploadScreenshot();
|
||||
}}
|
||||
className="btn btn-xs btn-outline mt-2"
|
||||
>
|
||||
Screenshot Test
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Widgets */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<Widget
|
||||
title="Logged Hours Today"
|
||||
value="6h 24m"
|
||||
icon={<Clock size={24} />}
|
||||
color="primary"
|
||||
/>
|
||||
<Widget
|
||||
title="Logged Hours This Week"
|
||||
value="4h 48m"
|
||||
icon={<TrendingUp size={24} />}
|
||||
color="success"
|
||||
/>
|
||||
<Widget
|
||||
title="Active Project"
|
||||
value={selectedProject ? selectedProject.projectName : "—"}
|
||||
icon={<ProjectorIcon size={24} />}
|
||||
color="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tracking Section */}
|
||||
{/* Tracking Section */}
|
||||
<div className="w-full mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-md font-bold">Tracking</span>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-100 border border-base-200 shadow-sm">
|
||||
<div className="card bg-base-100 border border-base-200 shadow-sm card-sm">
|
||||
<div className="card-body">
|
||||
{selectedProject ? (
|
||||
{(isStopping || isRefreshing) ? (
|
||||
// Skeleton loader for tracking section
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="text-center sm:text-left w-full">
|
||||
<div className="skeleton w-32 h-6 mx-auto sm:mx-0 mb-2"></div>
|
||||
<div className="skeleton w-48 h-4 mx-auto sm:mx-0"></div>
|
||||
</div>
|
||||
<div className="skeleton w-full sm:w-32 h-10 rounded-xl"></div>
|
||||
</div>
|
||||
) : selectedProject ? (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="text-center sm:text-left">
|
||||
{/* FIX: Use displayTime instead of always using server time */}
|
||||
<p className="text-lg font-extrabold text-success">
|
||||
{formatTime(selectedData?.elapsedTime || 0)}
|
||||
{formatTime(displayTime)}
|
||||
</p>
|
||||
<p className="text-xs text-base-content/70 mt-1">
|
||||
{selectedData?.isTracking ? "Current Session" : "Ready"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!selectedData?.isTracking ? (
|
||||
<button
|
||||
onClick={() => handleStartTracking(selectedProject)}
|
||||
className="btn btn-sm btn-success rounded-xl w-full sm:w-auto"
|
||||
>
|
||||
<Play className="w-3 h-3" /> Start Tracking
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-xs btn-error rounded-full"
|
||||
onClick={handleStopTracking}
|
||||
>
|
||||
<Square className="w-3 h-3" />
|
||||
Stop Tracking
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divider my-3" />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-base-content/70">Project:</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{selectedProject.projectName}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/70">Client:</span>{" "}
|
||||
<span>{selectedProject.clientName}</span>
|
||||
{/* Show tracking status */}
|
||||
{selectedData?.isTracking && (
|
||||
<div className="text-xs text-success mt-1">
|
||||
● Live Tracking
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/70">Status:</span>{" "}
|
||||
<span
|
||||
className={`badge ${selectedData?.isPaused
|
||||
? "badge-warning"
|
||||
: selectedData?.isTracking
|
||||
? "badge-success"
|
||||
: "badge-neutral"
|
||||
}`}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedData?.isTracking ? (
|
||||
<button
|
||||
onClick={() => handleStartTracking(selectedProject)}
|
||||
className="btn btn-sm btn-success rounded-xl w-full sm:w-auto"
|
||||
disabled={loadingStart}
|
||||
>
|
||||
{selectedData?.isPaused
|
||||
? "Paused"
|
||||
: selectedData?.isTracking
|
||||
? "Active"
|
||||
: "Ready"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/70">Start:</span>{" "}
|
||||
<span>
|
||||
{selectedData?.startTime
|
||||
? selectedData.startTime.toLocaleTimeString()
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
{loadingStart ? (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
) : (
|
||||
<Play className="w-3 h-3" />
|
||||
)}
|
||||
{loadingStart ? "Starting..." : "Start Tracking"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-xs btn-error rounded-full"
|
||||
onClick={handleStopTracking}
|
||||
disabled={loadingStop}
|
||||
>
|
||||
{loadingStop ? (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
) : (
|
||||
<Square className="w-3 h-3" />
|
||||
)}
|
||||
{loadingStop ? "Stopping..." : "Stop Tracking"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="divider my-1" />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-base-content/60 text-center">
|
||||
Select a project below to start tracking.
|
||||
{projectsData.length === 0
|
||||
? "No projects available."
|
||||
: "Select a project below to start tracking."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -216,41 +375,89 @@ const Dashboard: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{/* Projects Table */}
|
||||
<div>
|
||||
<p className="text-md mb-2 font-bold">Projects</p>
|
||||
<div className="overflow-x-auto rounded-box border border-base-content/10 bg-base-100 shadow-md">
|
||||
<table className="table table-zebra w-full text-sm">
|
||||
<div className="border border-base-content/10 bg-base-100 shadow-md min-h-[58vh] flex flex-col">
|
||||
{/* 🔹 Sticky Header */}
|
||||
<div className="sticky top-0 z-20 bg-base-100 border-b border-base-content/10 p-3 flex justify-between items-center">
|
||||
<p className="text-md font-bold">Projects</p>
|
||||
{!selectedData?.isTracking &&
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="btn btn-xs btn-ghost gap-1 text-warning"
|
||||
title="Refresh projects"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 text-warning ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
{/* 🔹 Table or Empty State or Skeleton */}
|
||||
{(isStopping || isRefreshing) ? (
|
||||
// Skeleton loader for projects table
|
||||
<div className="p-4 space-y-3">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="skeleton w-8 h-4"></div>
|
||||
<div className="skeleton w-32 h-4"></div>
|
||||
<div className="skeleton w-16 h-4"></div>
|
||||
<div className="skeleton w-20 h-8 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : projectsData.length > 0 ? (
|
||||
<div
|
||||
className="overflow-y-auto no-scrollbar"
|
||||
style={{
|
||||
maxHeight: "50vh",
|
||||
minHeight: "auto",
|
||||
}}
|
||||
>
|
||||
<table className="table table-zebra w-full text-[12px] ">
|
||||
<thead className="sticky top-0 bg-base-200 z-10">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Project</th>
|
||||
<th>Client</th>
|
||||
<th>Name</th>
|
||||
<th>Logs Hours</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projectsData.map((project) => {
|
||||
const isCurrentlySelected = selectedProject?.id === project.id;
|
||||
// ✅ FIX: Disable ALL projects when ANY project is tracking
|
||||
// Only enable selection when not tracking
|
||||
{projectsData.map((project: any, index: any) => {
|
||||
const isCurrentlySelected =
|
||||
selectedProject?.projectId === project.projectId;
|
||||
const isDisabled = isTracking && !isCurrentlySelected;
|
||||
const limitReached = isProjectLimitReached(project);
|
||||
const remainingTime = getFormattedRemainingTime(project);
|
||||
|
||||
return (
|
||||
<tr key={project.id}>
|
||||
<td>{project.id}</td>
|
||||
<td>{project.projectName}</td>
|
||||
<td>{project.clientName}</td>
|
||||
<tr key={project.projectId} className={`${project.status === 'INACTIVE' ? 'hidden' : ""} `}>
|
||||
<td className="text-xs opacity-70">{index + 1}</td>
|
||||
<td className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{project.projectName}</span>
|
||||
{limitReached && (
|
||||
<span className="text-xs text-error">Daily limit reached</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="font-mono text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs">
|
||||
{formatWorkTime(project.totalWorkTime)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
className={`btn btn-xs ${isCurrentlySelected
|
||||
? "btn-success"
|
||||
: "btn-primary"
|
||||
className={`btn btn-xs ${isCurrentlySelected ? "btn-success" : "btn-primary"
|
||||
}`}
|
||||
onClick={() => handleProjectSelect(project.id)}
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleProjectSelectLocal(project.projectId)}
|
||||
disabled={isDisabled || limitReached}
|
||||
>
|
||||
{isCurrentlySelected ? "Selected" : "Select"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -258,6 +465,11 @@ const Dashboard: React.FC = () => {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center flex-1 text-sm text-base-content/60">
|
||||
<NoData />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const HelpSupport: React.FC = () => {
|
|||
<Mail size={18} className="text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-base-content/70">Email</p>
|
||||
<p className="font-medium text-sm">support@vatechcorp.com</p>
|
||||
<p className="font-medium text-sm">support@docva.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ const HelpSupport: React.FC = () => {
|
|||
<Phone size={18} className="text-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-base-content/70">Phone</p>
|
||||
<p className="font-medium text-sm">+91 98765 43210</p>
|
||||
<p className="font-medium text-sm">+XXXX XXXXX</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import React from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
interface AdjustmentItem {
|
||||
id: string;
|
||||
date: string;
|
||||
loginTime: string;
|
||||
logoutTime: string;
|
||||
comment: string;
|
||||
status: string;
|
||||
requestedAt: string;
|
||||
adjustedHours: string;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AdjustmentTableProps {
|
||||
isTracking: boolean;
|
||||
adjustments: AdjustmentItem[];
|
||||
loading: boolean;
|
||||
lastRefreshed: Date | null;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AdjustmentsTable: React.FC<AdjustmentTableProps> = ({
|
||||
isTracking,
|
||||
adjustments,
|
||||
loading,
|
||||
lastRefreshed,
|
||||
onRefresh
|
||||
}) => {
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
// 🌍 Convert UTC → Local System Time
|
||||
const toLocal = (utcString: string, format: string) => {
|
||||
return DateTime.fromISO(utcString, { zone: "utc" })
|
||||
.setZone(DateTime.local().zoneName)
|
||||
.toFormat(format);
|
||||
};
|
||||
|
||||
const formatTime = (t: string) => toLocal(t, "HH:mm:ss");
|
||||
const formatDate = (d: string) => toLocal(d, "yyyy-MM-dd");
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await onRefresh();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderSkeleton = () =>
|
||||
Array.from({ length: 7 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td><div className="skeleton h-4 w-6"></div></td>
|
||||
<td><div className="skeleton h-4 w-20"></div></td>
|
||||
<td><div className="skeleton h-4 w-24"></div></td>
|
||||
<td><div className="skeleton h-4 w-20"></div></td>
|
||||
<td><div className="skeleton h-4 w-10"></div></td>
|
||||
<td><div className="skeleton h-4 w-24"></div></td>
|
||||
<td><div className="skeleton h-4 w-14"></div></td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-box border border-base-content/10 bg-base-100 shadow-md no-scrollbar"
|
||||
style={{
|
||||
height: isTracking ? "calc(100vh - 200px)" : "calc(100vh - 160px)",
|
||||
}}
|
||||
>
|
||||
<table className="table table-zebra w-full text-[11px]">
|
||||
<thead className="sticky top-0 bg-base-200 text-[12px] z-10">
|
||||
<tr className="text-center">
|
||||
<th>#</th>
|
||||
<th>Project</th>
|
||||
<th>Date</th>
|
||||
<th>In → Out</th>
|
||||
<th>Adjusted Hours</th>
|
||||
<th>Status</th>
|
||||
<th>Requested At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="text-center">
|
||||
{loading
|
||||
? renderSkeleton()
|
||||
: adjustments.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-4 text-base-content/50">
|
||||
No adjustments found
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: adjustments.map((item, idx) => (
|
||||
<tr key={item.id}>
|
||||
<td>{idx + 1}</td>
|
||||
|
||||
<td>
|
||||
<span className="badge badge-xs badge-info whitespace-nowrap">
|
||||
{item.project?.name || "N/A"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap">{formatDate(item.date)}</td>
|
||||
|
||||
<td className="whitespace-nowrap flex ">
|
||||
<span className="me-1">{formatTime(item.loginTime)} </span>
|
||||
→ {" "}
|
||||
<span className="ms-1">{formatTime(item.logoutTime)}</span>
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap">
|
||||
<span className="badge badge-xs badge-primary">
|
||||
{item.adjustedHours}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span
|
||||
className={`badge badge-xs ${
|
||||
item.status === "PENDING"
|
||||
? "badge-warning"
|
||||
: item.status === "APPROVED"
|
||||
? "badge-success"
|
||||
: "badge-error"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap">{toLocal(item.requestedAt, "yyyy-MM-dd HH:mm:ss")}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* {refreshing && adjustments.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="bg-base-200/50">
|
||||
<div className="flex justify-center py-2">
|
||||
{renderSkeleton()}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)} */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div className="flex-none flex justify-between items-center text-xs mt-2">
|
||||
<div className="text-base-content/60">
|
||||
{adjustments.length} adjustment{adjustments.length !== 1 ? "s" : ""} found
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{lastRefreshed && (
|
||||
<div className="text-base-content/60">
|
||||
Last updated: {toLocal(lastRefreshed.toISOString(), "HH:mm:ss")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="btn btn-xs btn-circle bg-warning"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdjustmentsTable;
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../../store";
|
||||
import PtoLeaveTab from './tabs/PtoLeaveTab';
|
||||
import SickLeaveTab from './tabs/SickLeaveTab';
|
||||
import AdjustmentTab from './tabs/AdjustmentTab';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type TabType = 'pto' | 'sick' | 'adjustment';
|
||||
|
||||
const ApplyLeaveAndAdjustmentsTime = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('pto');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false); // NEW
|
||||
const projects = useSelector((state: RootState) => state.projects.projectsData);
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
// ------- COMMON API CALL FOR BOTH TABS -------
|
||||
const submitLeave = async (payload: {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
leaveType: "PTO" | "SICK";
|
||||
comment: string;
|
||||
}) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
console.log("Leave /sick Payload:", payload);
|
||||
|
||||
const response = await window.electron.createPTOAndSickLeaveRequest(payload)
|
||||
console.log("Leave Payload:", response)
|
||||
if (response.success) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: response.message
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return { success: false, message: "Request failed" };
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/dashboard/leave-adjustments')
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="max-h-screen">
|
||||
<div className="w-full mx-auto">
|
||||
<div className='mb-3 flex justify-between items-center'>
|
||||
<h1 className="text-mb font-bold ">Apply Leave & Adjust Timesheet</h1>
|
||||
<button className="btn btn-xs btn-error text-base-content " onClick={handleBack}>Back</button>
|
||||
</div>
|
||||
{/* Tabs — Disabled while submitting */}
|
||||
<div role="tablist" className="tabs tabs-border w-full mb-1 opacity-100">
|
||||
<a
|
||||
role="tab"
|
||||
className={`tab text-xs font-semibold ${activeTab === "pto" ? "tab-active text-primary" : ""
|
||||
} ${isSubmitting ? "pointer-events-none opacity-50" : ""}`}
|
||||
onClick={() => setActiveTab("pto")}
|
||||
>
|
||||
PTO Leave
|
||||
</a>
|
||||
|
||||
<a
|
||||
role="tab"
|
||||
className={`tab text-xs font-semibold ${activeTab === "sick" ? "tab-active text-primary" : ""
|
||||
} ${isSubmitting ? "pointer-events-none opacity-50" : ""}`}
|
||||
onClick={() => setActiveTab("sick")}
|
||||
>
|
||||
Sick Leave
|
||||
</a>
|
||||
|
||||
<a
|
||||
role="tab"
|
||||
className={`tab text-xs font-semibold ${activeTab === "adjustment" ? "tab-active text-primary" : ""
|
||||
} ${isSubmitting ? "pointer-events-none opacity-50" : ""}`}
|
||||
onClick={() => setActiveTab("adjustment")}
|
||||
>
|
||||
Adjustment Submission
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="rounded-lg bg-base-100 overflow-hidden">
|
||||
<div className="px-8 py-4 overflow-y-auto no-scrollbar max-h-[81vh]">
|
||||
|
||||
{activeTab === "pto" && (
|
||||
<PtoLeaveTab submitLeave={submitLeave} isSubmitting={isSubmitting} />
|
||||
)}
|
||||
|
||||
{activeTab === "sick" && (
|
||||
<SickLeaveTab submitLeave={submitLeave} isSubmitting={isSubmitting} />
|
||||
)}
|
||||
|
||||
{activeTab === "adjustment" && (
|
||||
<AdjustmentTab projects={projects} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApplyLeaveAndAdjustmentsTime
|
||||
|
|
@ -1,566 +1,178 @@
|
|||
import React, { useState } from 'react';
|
||||
import AdjustmentModal from './AdjustmentModal';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useOutletContext } from "react-router-dom";
|
||||
import { TrackingState } from "../../layout/DashboardLayout";
|
||||
import LeaveTable from "./LeaveTable";
|
||||
import AdjustmentsTable from "./AdjustmentsTable";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
interface LeaveFormData {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
description: string;
|
||||
interface LeaveItem {
|
||||
leaveId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
totalDays: number;
|
||||
leaveType: string;
|
||||
comment: string;
|
||||
status: string;
|
||||
appliedOn: string;
|
||||
reviewedAt: string | null;
|
||||
rejectionReason: string | null;
|
||||
}
|
||||
|
||||
interface AdjustmentEntry {
|
||||
id: number;
|
||||
interface AdjustmentItem {
|
||||
id: string;
|
||||
date: string;
|
||||
loggedHours: number;
|
||||
loginTime: string;
|
||||
logoutTime: string;
|
||||
comment: string;
|
||||
status: string;
|
||||
requestedAt: string;
|
||||
adjustedHours: string;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
date: string;
|
||||
hours: number;
|
||||
task: string;
|
||||
status: "Completed" | "In Progress" | "Pending";
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
const data: LogEntry[] = [
|
||||
{ id: 1, date: "2025-11-01", hours: 8, task: "Frontend Development", status: "Completed" },
|
||||
{ id: 2, date: "2025-11-02", hours: 6, task: "API Integration", status: "In Progress" },
|
||||
{ id: 3, date: "2025-11-03", hours: 7.5, task: "UI Testing", status: "Pending" },
|
||||
{ id: 4, date: "2025-11-04", hours: 9, task: "Bug Fixing", status: "Completed" },
|
||||
{ id: 5, date: "2025-11-05", hours: 5.5, task: "Code Review", status: "Completed" },
|
||||
];
|
||||
|
||||
type TabType = 'pto' | 'sick' | 'adjustment';
|
||||
|
||||
const LeaveAndAdjustments: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('pto');
|
||||
const { isTracking } = useOutletContext<TrackingState>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// PTO State
|
||||
const [ptoLeave, setPtoLeave] = useState<LeaveFormData>({
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
description: ''
|
||||
});
|
||||
const [ptoErrors, setPtoErrors] = useState<Partial<LeaveFormData>>({});
|
||||
const { projectSummary } = useSelector((state: any) => state.projects);
|
||||
console.log("projectSummary46", projectSummary)
|
||||
|
||||
// Sick Leave State
|
||||
const [sickLeave, setSickLeave] = useState<LeaveFormData>({
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
description: ''
|
||||
});
|
||||
const [sickErrors, setSickErrors] = useState<Partial<LeaveFormData>>({});
|
||||
// Tabs state
|
||||
const [activeTab, setActiveTab] = useState<"leave" | "adjustments">("leave");
|
||||
|
||||
// Adjustment State
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string>('week');
|
||||
const [adjustmentData, setAdjustmentData] = useState<AdjustmentEntry[]>([
|
||||
{ id: 1, date: '2025-11-01', loggedHours: 8 },
|
||||
{ id: 2, date: '2025-11-02', loggedHours: 7.5 },
|
||||
{ id: 3, date: '2025-11-03', loggedHours: 8 },
|
||||
]);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editHours, setEditHours] = useState<string>('');
|
||||
// Data states
|
||||
const [leaves, setLeaves] = useState<LeaveItem[]>([]);
|
||||
const [adjustments, setAdjustments] = useState<AdjustmentItem[]>([]);
|
||||
|
||||
// Validation function
|
||||
const validateLeaveForm = (data: LeaveFormData): Partial<LeaveFormData> => {
|
||||
const errors: Partial<LeaveFormData> = {};
|
||||
// Loading states
|
||||
const [loadingLeaves, setLoadingLeaves] = useState(true);
|
||||
const [loadingAdjustments, setLoadingAdjustments] = useState(true);
|
||||
|
||||
if (!data.fromDate) {
|
||||
errors.fromDate = 'From date is required';
|
||||
}
|
||||
// Refresh state
|
||||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
|
||||
|
||||
if (!data.toDate) {
|
||||
errors.toDate = 'To date is required';
|
||||
}
|
||||
|
||||
if (data.fromDate && data.toDate && new Date(data.fromDate) > new Date(data.toDate)) {
|
||||
errors.toDate = 'To date must be after from date';
|
||||
}
|
||||
|
||||
if (!data.description.trim()) {
|
||||
errors.description = 'Description/Reason is required';
|
||||
}
|
||||
|
||||
return errors;
|
||||
const handleApplyLeaveAndAdjust = () => {
|
||||
navigate("/dashboard/leave-adjustments/apply");
|
||||
};
|
||||
|
||||
// PTO Handlers
|
||||
const handlePtoChange = (field: keyof LeaveFormData, value: string) => {
|
||||
setPtoLeave(prev => ({ ...prev, [field]: value }));
|
||||
if (ptoErrors[field]) {
|
||||
setPtoErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePtoSubmit = async () => {
|
||||
const errors = validateLeaveForm(ptoLeave);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setPtoErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch leaves data
|
||||
const fetchLeaves = async () => {
|
||||
setLoadingLeaves(true);
|
||||
try {
|
||||
console.log('Submitting PTO:', ptoLeave);
|
||||
alert('PTO request submitted successfully!');
|
||||
|
||||
setPtoLeave({ fromDate: '', toDate: '', description: '' });
|
||||
setPtoErrors({});
|
||||
} catch (error) {
|
||||
alert('Failed to submit request. Please try again.');
|
||||
console.error('Error:', error);
|
||||
const res: ApiResponse<{ leaves: LeaveItem[] }> = await window.electron.getLeaveList();
|
||||
if (res.success) {
|
||||
setLeaves(res?.data?.leaves || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error fetching leaves:", e);
|
||||
} finally {
|
||||
setLoadingLeaves(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sick Leave Handlers
|
||||
const handleSickChange = (field: keyof LeaveFormData, value: string) => {
|
||||
setSickLeave(prev => ({ ...prev, [field]: value }));
|
||||
if (sickErrors[field]) {
|
||||
setSickErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSickSubmit = async () => {
|
||||
const errors = validateLeaveForm(sickLeave);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setSickErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch adjustments data
|
||||
const fetchAdjustments = async () => {
|
||||
setLoadingAdjustments(true);
|
||||
try {
|
||||
console.log('Submitting Sick Leave:', sickLeave);
|
||||
alert('Sick Leave request submitted successfully!');
|
||||
|
||||
setSickLeave({ fromDate: '', toDate: '', description: '' });
|
||||
setSickErrors({});
|
||||
} catch (error) {
|
||||
alert('Failed to submit request. Please try again.');
|
||||
console.error('Error:', error);
|
||||
const res: ApiResponse<AdjustmentItem[]> = await window.electron.getAdjustmentsList(projectSummary?.vaId);
|
||||
if (res.success) {
|
||||
setAdjustments(res.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching adjustments:", err);
|
||||
} finally {
|
||||
setLoadingAdjustments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [editingEntry, setEditingEntry] = useState<AdjustmentEntry | null>(null);
|
||||
const [newHours, setNewHours] = useState<number>(0);
|
||||
// Combined refresh function
|
||||
const handleRefresh = async () => {
|
||||
// if (activeTab === "leave") {
|
||||
// await fetchLeaves();
|
||||
// } else {
|
||||
// await fetchAdjustments();
|
||||
// }
|
||||
|
||||
const handlePeriodChange = (value: string) => setSelectedPeriod(value);
|
||||
|
||||
const handleEditClick = (entry: AdjustmentEntry) => {
|
||||
setEditingEntry(entry);
|
||||
const modal = document.getElementById("editModal") as HTMLDialogElement;
|
||||
modal?.showModal();
|
||||
fetchLeaves();
|
||||
setTimeout(() => {
|
||||
fetchAdjustments();
|
||||
}, 0);
|
||||
setLastRefreshed(new Date());
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setEditingEntry(null);
|
||||
setNewHours(0)
|
||||
const modal = document.getElementById("editModal") as HTMLDialogElement;
|
||||
modal?.close();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
console.log("Saving:", { ...editingEntry, updatedHours: newHours });
|
||||
handleCloseModal();
|
||||
};
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchLeaves();
|
||||
setTimeout(() => {
|
||||
fetchAdjustments();
|
||||
}, 0);
|
||||
setLastRefreshed(new Date());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="w-full mx-auto">
|
||||
<main
|
||||
className="flex flex-col gap-2 overflow-hidden"
|
||||
// style={{
|
||||
// height: isTracking ? "calc(100vh - 40px)" : "",
|
||||
// }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className=" ">
|
||||
<h1 className="text-mb font-bold mb-2">Leave & Adjustments</h1>
|
||||
<div className="flex-none mb-2 flex justify-between items-center">
|
||||
<h2 className="text-md font-bold">Leave & Adjust Timesheet</h2>
|
||||
<button className="btn btn-xs btn-primary" onClick={handleApplyLeaveAndAdjust}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Container */}
|
||||
<div className=" rounded-lg bg-base-100 overflow-hidden">
|
||||
{/* Tabs Navigation */}
|
||||
<div className="flex ">
|
||||
{/* Tabs */}
|
||||
<div role="tablist" className="tabs tabs-bordered w-full">
|
||||
<button
|
||||
onClick={() => setActiveTab('pto')}
|
||||
className={`flex-1 px-6 py-3 text-xs font-semibold transition-all duration-300 relative ${activeTab === 'pto'
|
||||
? ' text-primary border-b-2 border-primary'
|
||||
: ''
|
||||
}`}
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "leave" ? "tab-active border-b-2 border-primary" : ""}`}
|
||||
onClick={() => setActiveTab("leave")}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 cursor-pointer">
|
||||
<svg
|
||||
className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
PTO Request
|
||||
</span>
|
||||
Leave
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('sick')}
|
||||
className={`flex-1 px-6 py-3 text-xs font-semibold transition-all duration-300 relative cursor-pointer ${activeTab === 'sick'
|
||||
? ' text-primary border-b-2 border-primary'
|
||||
: ''
|
||||
}`}
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "adjustments" ? "tab-active border-primary border-b-2" : ""}`}
|
||||
onClick={() => setActiveTab("adjustments")}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Sick Leave
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('adjustment')}
|
||||
className={`flex-1 px-6 py-3 text-xs font-semibold cursor-pointer transition-all duration-300 relative ${activeTab === 'adjustment'
|
||||
? ' text-primary border-b-2 border-primary'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Adjustments
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="px-8 py-4">
|
||||
{/* PTO Tab */}
|
||||
{activeTab === 'pto' && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="max-w-4xl ">
|
||||
<div className="mb-1">
|
||||
<h2 className="text-sm font-bold mb-2">Paid Time Off Request</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold ">From Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={ptoLeave.fromDate}
|
||||
onChange={(e) => handlePtoChange('fromDate', e.target.value)}
|
||||
className={`input input-bordered w-full focus:input-primary transition-all ${ptoErrors.fromDate ? 'input-error' : ''}`}
|
||||
{/* TABLE AREA - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar">
|
||||
{activeTab === "leave" && (
|
||||
<LeaveTable
|
||||
isTracking={isTracking}
|
||||
leaves={leaves}
|
||||
loading={loadingLeaves}
|
||||
lastRefreshed={lastRefreshed}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
{ptoErrors.fromDate && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error font-medium">{ptoErrors.fromDate}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">To Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={ptoLeave.toDate}
|
||||
onChange={(e) => handlePtoChange('toDate', e.target.value)}
|
||||
className={`input input-bordered w-full focus:input-primary transition-all ${ptoErrors.toDate ? 'input-error' : ''}`}
|
||||
{activeTab === "adjustments" && (
|
||||
<AdjustmentsTable
|
||||
isTracking={isTracking}
|
||||
adjustments={adjustments}
|
||||
loading={loadingAdjustments}
|
||||
lastRefreshed={lastRefreshed}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
{ptoErrors.toDate && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error font-medium">{ptoErrors.toDate}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control flex flex-col gap-1">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold ">Description / Reason</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={ptoLeave.description}
|
||||
onChange={(e) => handlePtoChange('description', e.target.value)}
|
||||
className={`textarea textarea-bordered h-20 w-full focus:textarea-primary transition-all ${ptoErrors.description ? 'textarea-error' : ''}`}
|
||||
placeholder="Please provide a reason for your PTO request..."
|
||||
/>
|
||||
{ptoErrors.description && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error font-medium">{ptoErrors.description}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handlePtoSubmit}
|
||||
className="btn btn-primary flex-1 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Submit Request
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPtoLeave({ fromDate: '', toDate: '', description: '' });
|
||||
setPtoErrors({});
|
||||
}}
|
||||
className="btn btn-secondary transition-all"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sick Leave Tab */}
|
||||
{activeTab === 'sick' && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="max-w-4xl ">
|
||||
<div className="mb-1">
|
||||
<h2 className="text-sm font-bold mb-2">Sick Leave Request</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold ">From Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={sickLeave.fromDate}
|
||||
onChange={(e) => handleSickChange('fromDate', e.target.value)}
|
||||
className={`input input-bordered w-full focus:input-primary transition-all ${sickErrors.fromDate ? 'input-error' : ''}`}
|
||||
/>
|
||||
{sickErrors.fromDate && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error font-medium">{sickErrors.fromDate}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold ">To Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={sickLeave.toDate}
|
||||
onChange={(e) => handleSickChange('toDate', e.target.value)}
|
||||
className={`input input-bordered w-full focus:input-primary transition-all ${sickErrors.toDate ? 'input-error' : ''}`}
|
||||
/>
|
||||
{sickErrors.toDate && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error font-medium">{sickErrors.toDate}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control flex flex-col gap-1">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold ">Description / Reason</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={sickLeave.description}
|
||||
onChange={(e) => handleSickChange('description', e.target.value)}
|
||||
className={`textarea textarea-bordered h-20 w-full focus:textarea-primary transition-all ${sickErrors.description ? 'textarea-error' : ''}`}
|
||||
placeholder="Please describe your illness or condition..."
|
||||
/>
|
||||
{sickErrors.description && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error font-medium">{sickErrors.description}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 ">
|
||||
<button
|
||||
onClick={handleSickSubmit}
|
||||
className="btn btn-primary flex-1 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Submit Request
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSickLeave({ fromDate: '', toDate: '', description: '' });
|
||||
setSickErrors({});
|
||||
}}
|
||||
className="btn btn-secondary transition-all"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Adjustment Submissions Tab */}
|
||||
{activeTab === 'adjustment' && (
|
||||
<div className="animate-fade-in">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-md font-bold mb-2">Time Adjustments</h2>
|
||||
<div className="form-control w-48">
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => handlePeriodChange(e.target.value)}
|
||||
className="select select-bordered focus:select-primary font-semibold"
|
||||
>
|
||||
<option value="week">This Week</option>
|
||||
<option value="month">This Month</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-lg border border-base-content/10">
|
||||
<table className="table w-full">
|
||||
<thead className="bg-base-100">
|
||||
<tr>
|
||||
<th className="font-bold text-base">Date</th>
|
||||
<th className="font-bold text-base">Logged Hours</th>
|
||||
<th className="font-bold text-base text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adjustmentData.map((entry) => (
|
||||
<tr key={entry.id} className="transition-colors">
|
||||
<td className="font-medium">
|
||||
{new Date(entry.date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-lg badge-ghost font-semibold">
|
||||
{entry.loggedHours} hrs
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<button
|
||||
onClick={() => handleEditClick(entry)}
|
||||
className="btn btn-primary btn-sm hover:shadow-md transition-all"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* No Data */}
|
||||
{adjustmentData.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<svg
|
||||
className="h-16 w-16 mx-auto mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p className="font-medium">No time entries found for this period</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Status */}
|
||||
<div className='mt-4'>
|
||||
<h1 className="text-md font-bold mb-2">View Status</h1>
|
||||
<div
|
||||
className="overflow-y-auto rounded-box border border-base-content/5 bg-base-100 shadow-md"
|
||||
style={{ minHeight: "150px", maxHeight: "300px" }}
|
||||
>
|
||||
<table className="table table-zebra w-full">
|
||||
{/* Table Head */}
|
||||
<thead className="sticky top-0 bg-base-200 z-10">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Date</th>
|
||||
<th>Logged Hours</th>
|
||||
<th>Task</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Table Body */}
|
||||
<tbody>
|
||||
{data.map((log, index) => (
|
||||
<tr key={log.id}>
|
||||
<td>{index + 1}</td>
|
||||
<td>{log.date}</td>
|
||||
<td className="font-medium">{log.hours} hrs</td>
|
||||
<td className="whitespace-normal break-words">{log.task}</td>
|
||||
<td>
|
||||
<div
|
||||
className={`badge ${log.status === "Completed"
|
||||
? "badge-success"
|
||||
: log.status === "In Progress"
|
||||
? "badge-info"
|
||||
: "badge-warning"
|
||||
}`}
|
||||
>
|
||||
{log.status}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
<style>{`
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<AdjustmentModal
|
||||
editingEntry={editingEntry}
|
||||
newHours={newHours}
|
||||
setNewHours={setNewHours}
|
||||
handleCloseModal={handleCloseModal}
|
||||
handleSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import React from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
interface LeaveItem {
|
||||
leaveId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
totalDays: number;
|
||||
leaveType: string;
|
||||
comment: string;
|
||||
status: string;
|
||||
appliedOn: string;
|
||||
reviewedAt: string | null;
|
||||
rejectionReason: string | null;
|
||||
}
|
||||
|
||||
interface LeaveTableProps {
|
||||
isTracking: boolean;
|
||||
leaves: LeaveItem[];
|
||||
loading: boolean;
|
||||
lastRefreshed: Date | null;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const LeaveTable: React.FC<LeaveTableProps> = ({
|
||||
isTracking,
|
||||
leaves,
|
||||
loading,
|
||||
lastRefreshed,
|
||||
onRefresh
|
||||
}) => {
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
// 🌍 Convert UTC → Local System Time
|
||||
const toLocal = (utcString: string, format: string) => {
|
||||
return DateTime.fromISO(utcString, { zone: "utc" })
|
||||
.setZone(DateTime.local().zoneName)
|
||||
.toFormat(format);
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => toLocal(d, "yyyy-MM-dd");
|
||||
const formatDateTime = (d: string) => toLocal(d, "yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await onRefresh();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderSkeletonRows = () =>
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<tr key={`skeleton-${index}`} className="hover:bg-base-200">
|
||||
<td><div className="skeleton h-4 w-6 bg-base-300"></div></td>
|
||||
<td><div className="skeleton h-4 w-20 bg-base-300"></div></td>
|
||||
<td><div className="skeleton h-4 w-28 bg-base-300"></div></td>
|
||||
<td><div className="skeleton h-4 w-16 bg-base-300"></div></td>
|
||||
<td><div className="skeleton h-4 w-10 bg-base-300"></div></td>
|
||||
<td><div className="skeleton h-4 w-24 bg-base-300"></div></td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const formatLastRefreshed = () =>
|
||||
lastRefreshed
|
||||
? DateTime.fromJSDate(lastRefreshed)
|
||||
.setZone(DateTime.local().zoneName)
|
||||
.toFormat("HH:mm:ss")
|
||||
: "Never";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Table Container */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-box border border-base-content/10 bg-base-100 shadow-md no-scrollbar"
|
||||
style={{
|
||||
height: isTracking ? "calc(100vh - 200px)" : "calc(100vh - 160px)",
|
||||
}}
|
||||
>
|
||||
<table className="table table-zebra w-full text-[11px]">
|
||||
<thead className="sticky top-0 bg-base-200 z-10 text-[12px]">
|
||||
<tr className="text-center">
|
||||
<th>#</th>
|
||||
<th>Leave Type</th>
|
||||
<th>Date From → To</th>
|
||||
<th>Status</th>
|
||||
<th>Total Days</th>
|
||||
<th>Applied On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="text-center">
|
||||
{loading ? (
|
||||
renderSkeletonRows()
|
||||
) : refreshing && leaves.length === 0 ? (
|
||||
renderSkeletonRows()
|
||||
) : leaves.length > 0 ? (
|
||||
leaves.map((leave, index) => (
|
||||
<tr key={leave.leaveId}>
|
||||
<td>{index + 1}</td>
|
||||
|
||||
<td>
|
||||
<span className="badge badge-xs badge-primary">
|
||||
{leave.leaveType}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap">
|
||||
<span className="">{formatDate(leave.dateFrom)} </span>
|
||||
→ {" "}
|
||||
<span className="">{formatDate(leave.dateTo)}</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span
|
||||
className={`badge badge-xs ${
|
||||
leave.status === "PENDING"
|
||||
? "badge-warning"
|
||||
: leave.status === "APPROVED"
|
||||
? "badge-success"
|
||||
: "badge-error"
|
||||
}`}
|
||||
>
|
||||
{leave.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>{leave.totalDays}</td>
|
||||
|
||||
<td className="whitespace-nowrap">{formatDateTime(leave.appliedOn)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-4 text-base-content/50">
|
||||
No leaves found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* {!refreshing && leaves.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-base-200/50">
|
||||
<div className="flex justify-center py-2">
|
||||
{renderSkeletonRows()}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)} */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex-none flex justify-between items-center text-xs mt-2">
|
||||
<div className="text-base-content/60">
|
||||
{leaves.length} leave{leaves.length !== 1 ? "s" : ""} found
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{lastRefreshed && (
|
||||
<div className="text-xs text-base-content/60">
|
||||
Last updated: {formatLastRefreshed()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="btn btn-xs btn-circle bg-warning"
|
||||
title="Refresh Leaves"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 size={15} className="animate-spin text-base-content" />
|
||||
) : (
|
||||
<RefreshCw size={15} className="text-base-content" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaveTable;
|
||||
|
|
@ -0,0 +1,684 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { DateTime } from 'luxon';
|
||||
import ReactQuill from 'react-quill-new';
|
||||
import { quillModules } from '../../../utils/quillConfig';
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '../../../helper/ToastContext';
|
||||
|
||||
interface Project {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
status?: string | boolean;
|
||||
}
|
||||
|
||||
interface ProjectSummaryResponse {
|
||||
totalWorkedHours: string;
|
||||
totalWorkedHoursDetailed: string;
|
||||
totalWorkedHoursDecimal: string;
|
||||
totalWorkedMinutes: string;
|
||||
dedicatedHours: number;
|
||||
pendingHours: string;
|
||||
firstLoginTime: string;
|
||||
lastLogoutTime: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
selectedProjectId?: string;
|
||||
selectedDate?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
comment?: string;
|
||||
general?: string;
|
||||
}
|
||||
|
||||
interface AdjustmentTabProps {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
const AdjustmentTab: React.FC<AdjustmentTabProps> = ({ projects }) => {
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
const [selectedDate, setSelectedDate] = useState("");
|
||||
const [totalAdjustment, setTotalAdjustment] = useState("0h 0m");
|
||||
const [pendingOverride, setPendingOverride] = useState<string | null>(null);
|
||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
const [projectSummary, setProjectSummary] = useState<ProjectSummaryResponse | null>(null);
|
||||
const [startTime, setStartTime] = useState("");
|
||||
const [endTime, setEndTime] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const quillRef = useRef<any>(null);
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Field validation functions
|
||||
const validateProjectId = (projectId: string): string | undefined => {
|
||||
if (!projectId) return 'Project selection is required';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateDate = (date: string): string | undefined => {
|
||||
if (!date) return 'Date is required';
|
||||
|
||||
const selectedDateObj = new Date(date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (selectedDateObj > today) return 'Cannot select future dates';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateTime = (time: string, fieldName: string): string | undefined => {
|
||||
if (!time) return `${fieldName} is required`;
|
||||
|
||||
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||
if (!timeRegex.test(time)) return 'Invalid time format';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateTimeRange = (start: string, end: string): string | undefined => {
|
||||
if (!start || !end) return undefined;
|
||||
|
||||
const startTime = DateTime.fromFormat(start, "HH:mm");
|
||||
const endTime = DateTime.fromFormat(end, "HH:mm");
|
||||
|
||||
if (!startTime.isValid || !endTime.isValid) return 'Invalid time values';
|
||||
if (endTime <= startTime) return 'End time must be after start time';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateDedicatedHours = (start: string, end: string): string | undefined => {
|
||||
if (!start || !end || !projectSummary) return undefined;
|
||||
|
||||
const startTime = DateTime.fromFormat(start, "HH:mm");
|
||||
const endTime = DateTime.fromFormat(end, "HH:mm");
|
||||
|
||||
if (!startTime.isValid || !endTime.isValid) return undefined;
|
||||
|
||||
const diff = endTime.diff(startTime, ["hours", "minutes"]).toObject();
|
||||
const workedHours = (diff.hours || 0) + (diff.minutes || 0) / 60;
|
||||
|
||||
if (workedHours > projectSummary.dedicatedHours) {
|
||||
return `Cannot exceed project's dedicated hours (${projectSummary.dedicatedHours} hrs)`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Real-time field validation
|
||||
const validateField = (field: keyof FormErrors, value: string) => {
|
||||
let error: string | undefined;
|
||||
|
||||
switch (field) {
|
||||
case 'selectedProjectId':
|
||||
error = validateProjectId(value);
|
||||
break;
|
||||
case 'selectedDate':
|
||||
error = validateDate(value);
|
||||
break;
|
||||
case 'startTime':
|
||||
error = validateTime(value, 'Start time');
|
||||
if (!error && endTime) {
|
||||
error = validateTimeRange(value, endTime) || validateDedicatedHours(value, endTime);
|
||||
}
|
||||
break;
|
||||
case 'endTime':
|
||||
error = validateTime(value, 'End time');
|
||||
if (!error && startTime) {
|
||||
error = validateTimeRange(startTime, value) || validateDedicatedHours(startTime, value);
|
||||
}
|
||||
break;
|
||||
case 'comment':
|
||||
if (!value.trim()) {
|
||||
error = 'Description is required';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
[field]: error
|
||||
}));
|
||||
|
||||
return !error;
|
||||
};
|
||||
|
||||
// Comprehensive form validation
|
||||
const validateForm = (): boolean => {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
errors.selectedProjectId = validateProjectId(selectedProjectId);
|
||||
errors.selectedDate = validateDate(selectedDate);
|
||||
errors.startTime = validateTime(startTime, 'Start time');
|
||||
errors.endTime = validateTime(endTime, 'End time');
|
||||
if (!comment.trim()) {
|
||||
errors.comment = 'Description is required';
|
||||
}
|
||||
|
||||
if (!errors.startTime && !errors.endTime) {
|
||||
errors.startTime = validateTimeRange(startTime, endTime);
|
||||
if (!errors.startTime) {
|
||||
errors.startTime = validateDedicatedHours(startTime, endTime);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredErrors = Object.fromEntries(
|
||||
Object.entries(errors).filter(([_, value]) => value !== undefined)
|
||||
) as FormErrors;
|
||||
|
||||
setFormErrors(filteredErrors);
|
||||
return Object.keys(filteredErrors).length === 0;
|
||||
};
|
||||
|
||||
const convertToLocalTime = (utcTimeString: string): string => {
|
||||
if (!utcTimeString) return '';
|
||||
|
||||
try {
|
||||
const localTime = DateTime.fromISO(utcTimeString, { zone: 'utc' }).toLocal();
|
||||
return localTime.toFormat('HH:mm');
|
||||
} catch (error) {
|
||||
console.error('Error converting time:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
console.log("Sending =>", selectedProjectId, selectedDate);
|
||||
setLoadingSummary(true);
|
||||
|
||||
const res = await window.electron.getProjectSummary(
|
||||
selectedProjectId,
|
||||
selectedDate
|
||||
);
|
||||
|
||||
console.log("📥 Summary Response:", res);
|
||||
|
||||
if (res.success) {
|
||||
setProjectSummary(res?.data?.summary);
|
||||
|
||||
if (res?.data?.summary?.firstLoginTime) {
|
||||
const localStartTime = convertToLocalTime(res?.data?.summary?.firstLoginTime);
|
||||
setStartTime(localStartTime);
|
||||
}
|
||||
|
||||
if (res?.data?.summary?.lastLogoutTime) {
|
||||
const localEndTime = convertToLocalTime(res?.data?.summary?.lastLogoutTime);
|
||||
setEndTime(localEndTime);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ Summary API failed:", res.message);
|
||||
setProjectSummary(null);
|
||||
setStartTime("");
|
||||
setEndTime("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Error fetching project summary:", err);
|
||||
setProjectSummary(null);
|
||||
setStartTime("");
|
||||
setEndTime("");
|
||||
} finally {
|
||||
setLoadingSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (!editor) return;
|
||||
|
||||
// Override image handler in toolbar
|
||||
editor.getModule("toolbar").handlers.image = () => {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", "image/*");
|
||||
input.click();
|
||||
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
const base64 = await convertToBase64(file);
|
||||
const range = editor.getSelection();
|
||||
if (range) {
|
||||
editor.insertEmbed(range.index, "image", base64);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// COMPLETELY disable Quill's default paste behavior
|
||||
const disableQuillPaste = () => {
|
||||
// Remove all existing paste event listeners from Quill
|
||||
const pasteListeners = (editor.root as any).__pasteHandlers || [];
|
||||
pasteListeners.forEach((handler: any) => {
|
||||
editor.root.removeEventListener('paste', handler);
|
||||
});
|
||||
|
||||
// Override the clipboard module to do nothing
|
||||
const clipboard = editor.getModule('clipboard');
|
||||
const originalPaste = clipboard.paste;
|
||||
clipboard.paste = (e: any) => {
|
||||
// Completely prevent Quill's default paste behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
disableQuillPaste();
|
||||
|
||||
// Our custom paste handler - this is the ONLY handler that will run
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
// Check for images first
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
const base64 = await convertToBase64(file);
|
||||
const range = editor.getSelection(true);
|
||||
if (range !== null) {
|
||||
// Insert the image
|
||||
editor.insertEmbed(range.index, "image", base64);
|
||||
// Move cursor after the image
|
||||
editor.setSelection(range.index + 1, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing pasted image:", error);
|
||||
}
|
||||
}
|
||||
return; // Exit after handling the first image
|
||||
}
|
||||
}
|
||||
|
||||
// If no images found, handle text
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text) {
|
||||
const range = editor.getSelection(true);
|
||||
if (range !== null) {
|
||||
editor.insertText(range.index, text);
|
||||
editor.setSelection(range.index + text.length, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add our custom paste handler with high priority
|
||||
editor.root.addEventListener("paste", handlePaste, true);
|
||||
|
||||
return () => {
|
||||
editor.root.removeEventListener("paste", handlePaste, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const convertToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId && selectedDate) {
|
||||
setProjectSummary(null)
|
||||
setStartTime("");
|
||||
setEndTime("");
|
||||
setComment("");
|
||||
setFormErrors({});
|
||||
setTotalAdjustment("0h 0m");
|
||||
fetchLogs();
|
||||
} else {
|
||||
setProjectSummary(null);
|
||||
setStartTime("");
|
||||
setEndTime("");
|
||||
}
|
||||
}, [selectedProjectId, selectedDate]);
|
||||
|
||||
// Auto calculations
|
||||
useEffect(() => {
|
||||
if (!projectSummary) return;
|
||||
if (!startTime || !endTime) return;
|
||||
|
||||
if (formErrors.startTime?.includes('time') || formErrors.startTime?.includes('dedicated')) {
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
startTime: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
const start = DateTime.fromFormat(startTime, "HH:mm");
|
||||
const end = DateTime.fromFormat(endTime, "HH:mm");
|
||||
|
||||
if (!start.isValid || !end.isValid) return;
|
||||
|
||||
if (end < start) {
|
||||
setTotalAdjustment("0h 0m");
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = end.diff(start, ["hours", "minutes"]).toObject();
|
||||
const workedHours = diff.hours || 0;
|
||||
const workedMinutes = diff.minutes || 0;
|
||||
|
||||
const totalWorkedDecimal = workedHours + workedMinutes / 60;
|
||||
const originalWorkedDecimal = Number(projectSummary.totalWorkedHoursDecimal);
|
||||
const adjustment = totalWorkedDecimal - originalWorkedDecimal;
|
||||
|
||||
const adjH = Math.floor(Math.abs(adjustment));
|
||||
const adjM = Math.round((Math.abs(adjustment) % 1) * 60);
|
||||
|
||||
const formatted = `${adjustment >= 0 ? "+" : "-"}${adjH}h ${adjM}m`;
|
||||
setTotalAdjustment(formatted);
|
||||
|
||||
const newPending = projectSummary.dedicatedHours - totalWorkedDecimal;
|
||||
const newPendingRounded = newPending.toFixed(2);
|
||||
|
||||
setPendingOverride(newPendingRounded);
|
||||
}, [startTime, endTime, projectSummary]);
|
||||
|
||||
const handleProjectSelect = (projectId: string) => {
|
||||
console.log("handleProjectSelect", projectId);
|
||||
setSelectedProjectId(projectId);
|
||||
validateField('selectedProjectId', projectId);
|
||||
};
|
||||
|
||||
const handleSelectedDate = (value: string) => {
|
||||
setSelectedDate(value);
|
||||
validateField('selectedDate', value);
|
||||
};
|
||||
|
||||
const handleStartTimeChange = (value: string) => {
|
||||
console.log("Start changed:", value);
|
||||
setStartTime(value);
|
||||
validateField('startTime', value);
|
||||
};
|
||||
|
||||
const handleEndTimeChange = (value: string) => {
|
||||
console.log("End changed:", value);
|
||||
setEndTime(value);
|
||||
validateField('endTime', value);
|
||||
};
|
||||
|
||||
const handleDescription = (value: string) => {
|
||||
setComment(value);
|
||||
validateField('comment', value);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedProjectId("");
|
||||
setSelectedDate("");
|
||||
setStartTime("");
|
||||
setEndTime("");
|
||||
setComment("");
|
||||
setFormErrors({});
|
||||
setTotalAdjustment("0h 0m");
|
||||
|
||||
// Properly reset the Quill editor
|
||||
if (quillRef.current) {
|
||||
const editor = quillRef.current.getEditor();
|
||||
editor.setContents([]); // Clear editor contents completely
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setFormErrors({});
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dedicatedHoursError = validateDedicatedHours(startTime, endTime);
|
||||
if (dedicatedHoursError) {
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
startTime: dedicatedHoursError
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
projectId: selectedProjectId,
|
||||
date: selectedDate,
|
||||
loginTime: `${startTime}:00`,
|
||||
logoutTime: `${endTime}:00`,
|
||||
comment
|
||||
};
|
||||
|
||||
console.log("Submitting payload:", payload);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await window.electron.createTimesheet(payload);
|
||||
console.log("API Response:", res);
|
||||
|
||||
if (res?.success) {
|
||||
console.log("Timesheet Created Successfully");
|
||||
resetForm();
|
||||
// Show success toast instead of alert
|
||||
showToast("Timesheet Request submitted successfully!", "success");
|
||||
|
||||
// Navigate after a small delay
|
||||
setTimeout(() => {
|
||||
navigate('/dashboard/leave-adjustments');
|
||||
}, 300);
|
||||
} else {
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
general: res?.message || "Failed to create timesheet"
|
||||
}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Submission error:", err);
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
general: err.message || "Something went wrong while saving timesheet"
|
||||
}));
|
||||
showToast(err.message || "Something went wrong while saving timesheet", "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl ">
|
||||
<div className="space-y-3">
|
||||
{/* General Error Alert */}
|
||||
{formErrors.general && (
|
||||
<div className="alert alert-error shadow-sm my-1 px-6 py-1 text-xs text-base-100">
|
||||
<span>{formErrors.general}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Project Selection */}
|
||||
<div className="form-control col-span-2">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Select project</span>
|
||||
</label>
|
||||
<select
|
||||
className={`select select-sm ${formErrors.selectedProjectId ? 'select-error' : ''}`}
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => handleProjectSelect(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Pick a project
|
||||
</option>
|
||||
{projects.map((item, index) => (
|
||||
<option key={item.projectId || index} value={item.projectId} className={`${item.status ? "" : ""}`}>
|
||||
{item.projectName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{formErrors.selectedProjectId && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error text-xs font-medium">{formErrors.selectedProjectId}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date Selection */}
|
||||
<div className="form-control col-span-2">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Select timesheet date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => handleSelectedDate(e.target.value)}
|
||||
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${formErrors.selectedDate ? 'input-error' : ''}`}
|
||||
/>
|
||||
{formErrors.selectedDate && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error text-xs font-medium">{formErrors.selectedDate}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Total Time */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm ">Current total time</span>
|
||||
</label>
|
||||
{loadingSummary ? (
|
||||
<div className="skeleton h-9 w-full"></div>
|
||||
) :
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
projectSummary
|
||||
? `${pendingOverride ? (projectSummary.dedicatedHours - Number(pendingOverride)).toFixed(2) : projectSummary.totalWorkedHoursDecimal} hrs`
|
||||
: "0 hrs"
|
||||
}
|
||||
readOnly
|
||||
className="input input-bordered input-sm w-full bg-base-200"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Current Pending Time */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Current pending time</span>
|
||||
</label>
|
||||
{loadingSummary ? (
|
||||
<div className="skeleton h-9 w-full"></div>
|
||||
) :
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
projectSummary
|
||||
? `${pendingOverride ?? projectSummary.pendingHours} hrs`
|
||||
: "0 hrs"
|
||||
}
|
||||
readOnly
|
||||
className="input input-bordered input-sm w-full bg-base-200"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Start Time */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Start time</span>
|
||||
</label>
|
||||
{loadingSummary ? (
|
||||
<div className="skeleton h-9 w-full"></div>
|
||||
) :
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => handleStartTimeChange(e.target.value)}
|
||||
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${formErrors.startTime ? 'input-error' : ''}`}
|
||||
/>
|
||||
}
|
||||
{formErrors.startTime && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error text-xs font-medium">{formErrors.startTime}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* End Time */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm ">End time</span>
|
||||
</label>
|
||||
{loadingSummary ? (
|
||||
<div className="skeleton h-9 w-full"></div>
|
||||
) :
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => handleEndTimeChange(e.target.value)}
|
||||
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${formErrors.endTime ? 'input-error' : ''}`}
|
||||
/>
|
||||
}
|
||||
{formErrors.endTime && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error text-xs font-medium">{formErrors.endTime}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total Time Adjustment */}
|
||||
<div className="form-control col-span-2">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Total time adjustment</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Calculated automatically"
|
||||
value={totalAdjustment}
|
||||
readOnly
|
||||
className="input input-bordered input-sm w-full bg-base-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description with ReactQuill */}
|
||||
<div className="form-control flex flex-col gap-1 col-span-2 mb-12">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Description / Reason</span>
|
||||
</label>
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={comment}
|
||||
modules={quillModules}
|
||||
onChange={handleDescription}
|
||||
className="h-40"
|
||||
// placeholder="Please enter the task name or leave notes to help us better understand your request."
|
||||
/>
|
||||
</div>
|
||||
{formErrors.comment && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-error text-xs font-medium">{formErrors.comment}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className={`btn btn-primary flex-1 hover:shadow-lg transition-all duration-300 `}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Request'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdjustmentTab;
|
||||
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
// PtoLeaveTab.tsx
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { quillModules } from "../../../utils/quillConfig";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import Quill from "quill";
|
||||
import "quill/dist/quill.snow.css";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useToast } from "../../../helper/ToastContext";
|
||||
|
||||
interface LeaveFormData {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const PtoLeaveTab: React.FC<{
|
||||
submitLeave: Function;
|
||||
isSubmitting: boolean;
|
||||
}> = ({ submitLeave, isSubmitting }) => {
|
||||
|
||||
const [ptoLeave, setPtoLeave] = useState<LeaveFormData>({
|
||||
fromDate: "",
|
||||
toDate: "",
|
||||
comment: "",
|
||||
});
|
||||
const [serverError, setServerError] = useState("");
|
||||
const quillRef = useRef<any>(null);
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { showToast } = useToast();
|
||||
|
||||
console.log("ptoLeave", ptoLeave)
|
||||
const [errors, setErrors] = useState<Partial<LeaveFormData>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (!editor) return;
|
||||
|
||||
// Override image handler in toolbar
|
||||
editor.getModule("toolbar").handlers.image = () => {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", "image/*");
|
||||
input.click();
|
||||
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
const base64 = await convertToBase64(file);
|
||||
const range = editor.getSelection();
|
||||
if (range) {
|
||||
editor.insertEmbed(range.index, "image", base64);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// COMPLETELY disable Quill's default paste behavior
|
||||
const disableQuillPaste = () => {
|
||||
// Remove all existing paste event listeners from Quill
|
||||
const pasteListeners = (editor.root as any).__pasteHandlers || [];
|
||||
pasteListeners.forEach((handler: any) => {
|
||||
editor.root.removeEventListener('paste', handler);
|
||||
});
|
||||
|
||||
// Override the clipboard module to do nothing
|
||||
const clipboard = editor.getModule('clipboard');
|
||||
const originalPaste = clipboard.paste;
|
||||
clipboard.paste = (e: any) => {
|
||||
// Completely prevent Quill's default paste behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
disableQuillPaste();
|
||||
|
||||
// Our custom paste handler - this is the ONLY handler that will run
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
// Check for images first
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
const base64 = await convertToBase64(file);
|
||||
const range = editor.getSelection(true);
|
||||
if (range !== null) {
|
||||
// Insert the image
|
||||
editor.insertEmbed(range.index, "image", base64);
|
||||
// Move cursor after the image
|
||||
editor.setSelection(range.index + 1, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing pasted image:", error);
|
||||
}
|
||||
}
|
||||
return; // Exit after handling the first image
|
||||
}
|
||||
}
|
||||
|
||||
// If no images found, handle text
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text) {
|
||||
const range = editor.getSelection(true);
|
||||
if (range !== null) {
|
||||
editor.insertText(range.index, text);
|
||||
editor.setSelection(range.index + text.length, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add our custom paste handler with high priority
|
||||
editor.root.addEventListener("paste", handlePaste, true);
|
||||
|
||||
return () => {
|
||||
editor.root.removeEventListener("paste", handlePaste, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const convertToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// ... rest of your component code remains the same
|
||||
const validate = () => {
|
||||
const e: Partial<LeaveFormData> = {};
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const from = ptoLeave.fromDate ? new Date(ptoLeave.fromDate) : null;
|
||||
const to = ptoLeave.toDate ? new Date(ptoLeave.toDate) : null;
|
||||
|
||||
if (!from) {
|
||||
e.fromDate = "From date is required";
|
||||
} else if (from < today) {
|
||||
e.fromDate = "From date cannot be in the past";
|
||||
}
|
||||
|
||||
if (!to) {
|
||||
e.toDate = "To date is required";
|
||||
} else if (to < today) {
|
||||
e.toDate = "To date cannot be in the past";
|
||||
}
|
||||
|
||||
if (from && to && to < from) {
|
||||
e.toDate = "To date cannot be before From date";
|
||||
}
|
||||
|
||||
if (!ptoLeave.comment.trim()) {
|
||||
e.comment = "Comment required";
|
||||
}
|
||||
|
||||
return e;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
const emptyForm: LeaveFormData = {
|
||||
fromDate: "",
|
||||
toDate: "",
|
||||
comment: ""
|
||||
};
|
||||
|
||||
setPtoLeave(emptyForm);
|
||||
setErrors({});
|
||||
|
||||
if (quillRef.current) {
|
||||
const editor = quillRef.current.getEditor();
|
||||
editor.setContents([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof LeaveFormData, value: string) => {
|
||||
setPtoLeave(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setServerError("")
|
||||
const e = validate();
|
||||
if (Object.keys(e).length) return setErrors(e);
|
||||
|
||||
const result = await submitLeave({
|
||||
dateFrom: ptoLeave.fromDate,
|
||||
dateTo: ptoLeave.toDate,
|
||||
leaveType: "PTO",
|
||||
comment: ptoLeave.comment,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
resetForm();
|
||||
// Show success toast instead of alert
|
||||
showToast("PTO Request submitted successfully!", "success");
|
||||
|
||||
// Navigate after a small delay
|
||||
setTimeout(() => {
|
||||
navigate('/dashboard/leave-adjustments');
|
||||
}, 300);
|
||||
} else {
|
||||
setServerError(result.message || "Something went wrong");
|
||||
showToast(result.message || "Failed to submit PTO request", "error");
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<div className="max-w-4xl space-y-3 ">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
{/* From Date */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">From Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
disabled={isSubmitting}
|
||||
value={ptoLeave.fromDate}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
onChange={(e) => handleChange('fromDate', e.target.value)}
|
||||
className={`input input-bordered input-sm ${errors.fromDate ? "input-error" : ""
|
||||
}`}
|
||||
/>
|
||||
{errors.fromDate && <p className="text-error text-xs">{errors.fromDate}</p>}
|
||||
</div>
|
||||
|
||||
{/* To Date */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">To Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
disabled={isSubmitting}
|
||||
value={ptoLeave.toDate}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
onChange={(e) => handleChange('toDate', e.target.value)}
|
||||
className={`input input-bordered input-sm ${errors.toDate ? "input-error" : ""
|
||||
}`}
|
||||
/>
|
||||
{errors.toDate && <p className="text-error text-xs">{errors.toDate}</p>}
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="form-control mb-10 col-span-2">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Description</span>
|
||||
</label>
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={ptoLeave.comment}
|
||||
modules={quillModules}
|
||||
onChange={(val) => handleChange('comment', val)}
|
||||
className="h-60"
|
||||
/>
|
||||
</div>
|
||||
{errors.comment && <p className="text-error text-xs">{errors.comment}</p>}
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<p className="text-red-500 font-medium text-center text-xs">{serverError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="btn btn-primary flex-1 w-full"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : (
|
||||
"Submit PTO Request"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PtoLeaveTab;
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { quillModules } from "../../../utils/quillConfig";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import Quill from "quill";
|
||||
import "quill/dist/quill.snow.css"; // correct CSS
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '../../../helper/ToastContext';
|
||||
|
||||
interface LeaveFormData {
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const SickLeaveTab: React.FC<{
|
||||
submitLeave: Function;
|
||||
isSubmitting: boolean;
|
||||
}> = ({ submitLeave, isSubmitting }) => {
|
||||
const [sickLeave, setSickLeave] = useState<LeaveFormData>({
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
comment: ''
|
||||
});
|
||||
const [serverError, setServerError] = useState("");
|
||||
const quillRef = useRef<any>(null);
|
||||
const [sickErrors, setSickErrors] = useState<Partial<LeaveFormData>>({});
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (!editor) return;
|
||||
|
||||
// Override image handler in toolbar
|
||||
editor.getModule("toolbar").handlers.image = () => {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", "image/*");
|
||||
input.click();
|
||||
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
const base64 = await convertToBase64(file);
|
||||
const range = editor.getSelection();
|
||||
if (range) {
|
||||
editor.insertEmbed(range.index, "image", base64);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// COMPLETELY disable Quill's default paste behavior
|
||||
const disableQuillPaste = () => {
|
||||
// Remove all existing paste event listeners from Quill
|
||||
const pasteListeners = (editor.root as any).__pasteHandlers || [];
|
||||
pasteListeners.forEach((handler: any) => {
|
||||
editor.root.removeEventListener('paste', handler);
|
||||
});
|
||||
|
||||
// Override the clipboard module to do nothing
|
||||
const clipboard = editor.getModule('clipboard');
|
||||
const originalPaste = clipboard.paste;
|
||||
clipboard.paste = (e: any) => {
|
||||
// Completely prevent Quill's default paste behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
disableQuillPaste();
|
||||
|
||||
// Our custom paste handler - this is the ONLY handler that will run
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
// Check for images first
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
const base64 = await convertToBase64(file);
|
||||
const range = editor.getSelection(true);
|
||||
if (range !== null) {
|
||||
// Insert the image
|
||||
editor.insertEmbed(range.index, "image", base64);
|
||||
// Move cursor after the image
|
||||
editor.setSelection(range.index + 1, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing pasted image:", error);
|
||||
}
|
||||
}
|
||||
return; // Exit after handling the first image
|
||||
}
|
||||
}
|
||||
|
||||
// If no images found, handle text
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text) {
|
||||
const range = editor.getSelection(true);
|
||||
if (range !== null) {
|
||||
editor.insertText(range.index, text);
|
||||
editor.setSelection(range.index + text.length, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add our custom paste handler with high priority
|
||||
editor.root.addEventListener("paste", handlePaste, true);
|
||||
|
||||
return () => {
|
||||
editor.root.removeEventListener("paste", handlePaste, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const convertToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const editor = quillRef.current?.getEditor();
|
||||
|
||||
if (!editor) return;
|
||||
|
||||
editor.getModule("clipboard").addMatcher(Node.ELEMENT_NODE, (node: any, delta: any) => {
|
||||
return delta;
|
||||
});
|
||||
|
||||
editor.root.addEventListener("paste", async (e: any) => {
|
||||
const items = e.clipboardData?.items;
|
||||
|
||||
if (!items) return;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.indexOf("image") === 0) {
|
||||
const file = item.getAsFile();
|
||||
const base64 = await convertToBase64(file);
|
||||
|
||||
const range = editor.getSelection();
|
||||
editor.insertEmbed(range.index, "image", base64);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
const validateLeaveForm = (data: LeaveFormData): Partial<LeaveFormData> => {
|
||||
const errors: Partial<LeaveFormData> = {};
|
||||
|
||||
if (!data.fromDate) {
|
||||
errors.fromDate = 'From date is required';
|
||||
}
|
||||
|
||||
if (!data.toDate) {
|
||||
errors.toDate = 'To date is required';
|
||||
}
|
||||
|
||||
if (!data.comment.trim()) {
|
||||
errors.comment = 'Comment is required';
|
||||
}
|
||||
|
||||
if (data.fromDate && data.toDate && new Date(data.fromDate) > new Date(data.toDate)) {
|
||||
errors.toDate = 'To date must be after from date';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleSickChange = (field: keyof LeaveFormData, value: string) => {
|
||||
setSickLeave(prev => ({ ...prev, [field]: value }));
|
||||
if (sickErrors[field]) {
|
||||
setSickErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
const emptyForm: LeaveFormData = {
|
||||
fromDate: "",
|
||||
toDate: "",
|
||||
comment: ""
|
||||
};
|
||||
setSickLeave(emptyForm);
|
||||
setSickErrors({});
|
||||
|
||||
// Properly reset the Quill editor
|
||||
if (quillRef.current) {
|
||||
const editor = quillRef.current.getEditor();
|
||||
editor.setContents([]); // Clear editor contents completely
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setServerError("")
|
||||
const errors = validateLeaveForm(sickLeave);
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setSickErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await submitLeave({
|
||||
dateFrom: sickLeave.fromDate,
|
||||
dateTo: sickLeave.toDate,
|
||||
leaveType: "SICK",
|
||||
comment: sickLeave.comment,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
resetForm();
|
||||
// Show success toast instead of alert
|
||||
showToast("SICK Request submitted successfully!", "success");
|
||||
|
||||
// Navigate after a small delay
|
||||
setTimeout(() => {
|
||||
navigate('/dashboard/leave-adjustments');
|
||||
}, 300);
|
||||
} else {
|
||||
setServerError(result.message || "Something went wrong");
|
||||
showToast(result.message || "Failed to submit SICK request", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-4xl">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* From Date */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">From Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={sickLeave.fromDate}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => handleSickChange('fromDate', e.target.value)}
|
||||
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${sickErrors.fromDate ? 'input-error' : ''
|
||||
}`}
|
||||
/>
|
||||
{sickErrors.fromDate && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-xs text-error font-medium">{sickErrors.fromDate}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* To Date */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">To Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={sickLeave.toDate}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => handleSickChange('toDate', e.target.value)}
|
||||
className={`input input-bordered input-sm w-full focus:input-primary transition-all ${sickErrors.toDate ? 'input-error' : ''
|
||||
}`}
|
||||
/>
|
||||
{sickErrors.toDate && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-xs text-error font-medium">{sickErrors.toDate}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description/Comment Field */}
|
||||
<div className="form-control flex flex-col gap-1 col-span-2 mb-10">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold text-sm">Description</span>
|
||||
</label>
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={sickLeave.comment}
|
||||
modules={quillModules}
|
||||
onChange={(val) => handleSickChange('comment', val)}
|
||||
className="h-60"
|
||||
/>
|
||||
</div>
|
||||
{sickErrors.comment && (
|
||||
<label className="label">
|
||||
<span className="label-text-alt text-xs text-error font-medium">{sickErrors.comment}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<p className="text-red-500 font-medium text-center text-xs">{serverError}</p>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="btn btn-primary flex-1 w-full"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : (
|
||||
"Submit Sick Request"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SickLeaveTab;
|
||||
|
|
@ -1,44 +1,87 @@
|
|||
import React, { useState, useEffect, useRef, FormEvent, ChangeEvent } from 'react'
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { setUserSlice } from '../store/userSlice';
|
||||
import DocVaLogoLight from '../../assets/logo-colored-vertical.svg'
|
||||
import DocVaLogoDark from '../../assets/logo-white-vertical.svg'
|
||||
import { useTheme } from '../helper/ThemeToggle';
|
||||
import { Eye, EyeOff, KeyRound, Mail } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
const Login = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
interface LoginProps {
|
||||
setUser: (user: User) => void
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ setUser }) => {
|
||||
const [formData, setFormData] = useState<User>({ email: '', password: '' })
|
||||
const inputRef = useRef<HTMLFormElement | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useDispatch();
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isForgotMode, setIsForgotMode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value })
|
||||
}
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement> | KeyboardEvent) => {
|
||||
const logo = theme === "dark" ? DocVaLogoDark : DocVaLogoLight;
|
||||
|
||||
|
||||
|
||||
const handleLogin = async (e: FormEvent<HTMLFormElement> | KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const { email, password } = formData
|
||||
if (email === "" || password === "") return
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await window.electron.login({ email, password });
|
||||
console.log(response)
|
||||
if (response.success) {
|
||||
localStorage.setItem("user", JSON.stringify(formData));
|
||||
setUser(response.user);
|
||||
// setUser(response.user);
|
||||
dispatch(setUserSlice({
|
||||
id: response.user.id,
|
||||
name: response.user.name,
|
||||
email: response.user.email,
|
||||
}));
|
||||
navigate("/")
|
||||
}
|
||||
else if (response.success == false) {
|
||||
console.log(response.error.message)
|
||||
setErrorMessage(response.error.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const handleForgotPassword = async (e: FormEvent<HTMLFormElement> | KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await window.electron.forgotPassword(email);
|
||||
console.log("Forgot password response:", res);
|
||||
alert("Password reset link sent to your email");
|
||||
setIsForgotMode(false);
|
||||
} catch (error) {
|
||||
console.error("Forgot password error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect Enter key press
|
||||
useEffect(() => {
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit(e)
|
||||
if (!isForgotMode) {
|
||||
handleLogin(e)
|
||||
} else {
|
||||
handleForgotPassword(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,28 +89,18 @@ const Login: React.FC<LoginProps> = ({ setUser }) => {
|
|||
inputElement?.addEventListener('keydown', handleEnter)
|
||||
|
||||
return () => inputElement?.removeEventListener('keydown', handleEnter)
|
||||
}, [formData])
|
||||
}, [email, password])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/10 via-base-200 to-secondary/10 p-4">
|
||||
<>
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-base-200 to-secondary/10 p-4">
|
||||
<div className="card w-full max-w-md shadow-2xl bg-base-100 backdrop-blur-sm">
|
||||
<div className="card-body">
|
||||
<div className="flex justify-center items-center mb-4">
|
||||
<div className="avatar placeholder">
|
||||
<div className="bg-primary text-primary-content rounded-full w-16 h-16 flex items-center justify-center">
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/* <div className="bg-primary text-primary-content rounded-full w-20 h-20 flex items-center justify-center"> */}
|
||||
<img src={logo} className='w-35 h-23' />
|
||||
{/* </div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -77,70 +110,100 @@ const Login: React.FC<LoginProps> = ({ setUser }) => {
|
|||
<form
|
||||
ref={inputRef}
|
||||
className="flex flex-col items-center justify-center w-full"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={isForgotMode ? handleForgotPassword : handleLogin}
|
||||
>
|
||||
<div className="form-control w-90 px-5">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Email Address</span>
|
||||
</label>
|
||||
<label className="input input-bordered flex items-center gap-2">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 opacity-70"
|
||||
>
|
||||
<path d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z" />
|
||||
<path d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z" />
|
||||
</svg>
|
||||
<label className="input input-bordered flex items-center gap-2" tabIndex={0}>
|
||||
{/* <Mail size={18} /> */}
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
className="grow"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control w-90 px-5 mt-4">
|
||||
{!isForgotMode && (
|
||||
<div className="form-control w-90 px-5 mt-3">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Password</span>
|
||||
</label>
|
||||
<label className="input input-bordered flex items-center gap-2">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 opacity-70"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<label className="input input-bordered flex items-center gap-2" tabIndex={0}>
|
||||
{/* <KeyRound size={18} /> */}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
className="grow"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Toggle Icon */}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={18} className="opacity-70" />
|
||||
) : (
|
||||
<Eye size={18} className="opacity-70" />
|
||||
)}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-control w-full px-5 mt-6">
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
Sign In
|
||||
|
||||
<div className="form-control w-90 px-5 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{loading
|
||||
? "Please wait..."
|
||||
: isForgotMode
|
||||
? "Send Reset Link"
|
||||
: "Login"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center mt-4">
|
||||
{isForgotMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsForgotMode(false)}
|
||||
className="link link-primary text-sm"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsForgotMode(true)}
|
||||
className="link link-primary text-sm"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && <span className='text-red-500 font-semibold mt-2'>{errorMessage}...!</span>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import React from "react";
|
||||
|
||||
const NoData = ({ message = "No data available", subText = "Please add new items or check back later." }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[67vh] p-6 text-center">
|
||||
{/* SVG Illustration */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 600 360"
|
||||
className="w-64 h-40 mb-6 text-primary"
|
||||
>
|
||||
<rect
|
||||
x="38"
|
||||
y="56"
|
||||
width="524"
|
||||
height="208"
|
||||
rx="14"
|
||||
fill="currentColor"
|
||||
opacity="0.05"
|
||||
/>
|
||||
<g transform="translate(125, 68)">
|
||||
<rect
|
||||
x="0"
|
||||
y="24"
|
||||
width="298"
|
||||
height="150"
|
||||
rx="8"
|
||||
fill="white"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<g transform="translate(18, 43)" fill="currentColor" opacity="0.1">
|
||||
<rect x="0" y="0" width="160" height="12" rx="6" />
|
||||
<rect x="0" y="24" width="110" height="12" rx="6" />
|
||||
<rect x="0" y="48" width="140" height="12" rx="6" />
|
||||
</g>
|
||||
<g
|
||||
transform="translate(160, 72) scale(1.05)"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="animate-pulse"
|
||||
>
|
||||
<circle cx="24" cy="24" r="18" fill="none" />
|
||||
<path d="M39 39 L58 58" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Text section */}
|
||||
<div className="card bg-base-100 w-full max-w-md">
|
||||
<div className="card-body items-center">
|
||||
<h2 className="card-title text-lg font-semibold text-base-content">
|
||||
{message}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoData;
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../../store";
|
||||
|
||||
const PaysSlips = () => {
|
||||
const { projectSummary } =
|
||||
useSelector((state: any) => state.projects);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectSummary?.vaId) return;
|
||||
|
||||
fetchPayslips(projectSummary?.vaId);
|
||||
|
||||
}, [projectSummary?.vaId]);
|
||||
|
||||
const fetchPayslips = async (vaId: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("getPayslips" , vaId)
|
||||
const result = await window.electron.getPayslips(vaId);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message || "Failed to load payslips");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = result.data;
|
||||
|
||||
if (!response || response.length === 0) {
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(response , "getPayslips")
|
||||
|
||||
// Extract payrolls array from first index
|
||||
const payrolls = response[0]?.payrolls || [];
|
||||
|
||||
setRows(payrolls);
|
||||
} catch (err: any) {
|
||||
setError("An unexpected error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 max-h-screen">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-md font-bold">Payslips</h2>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto rounded-box border border-base-content/10 bg-base-100 shadow-md max-h-[80vh] min-h-[83vh] no-scrollbar">
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-10">
|
||||
<span className="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center text-red-500 py-5">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
No payslip data available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<div className="overflow-x-auto w-full no-scrollbar">
|
||||
<table className="table table-zebra w-full text-[12px] min-w-[650px]">
|
||||
<thead className="sticky top-0 bg-base-200 z-10">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
<th>Total Hours</th>
|
||||
<th>Total Pay</th>
|
||||
{/* <th>Action</th> */}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.id}>
|
||||
<td>{index + 1}</td>
|
||||
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${
|
||||
row.status === "Paid"
|
||||
? "badge-success"
|
||||
: "badge-warning"
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>{row.payrollSchedule}</td>
|
||||
|
||||
<td>{row.totalHoursFormatted}</td>
|
||||
|
||||
<td>${row.totalPayAmount.toFixed(2)}</td>
|
||||
|
||||
{/* <td>
|
||||
<button
|
||||
className="btn btn-xs btn-primary"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</td> */}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaysSlips;
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { TrackingState } from "../../layout/DashboardLayout";
|
||||
import { RefreshCw, Loader2 } from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
interface ScheduleItem {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
status: string | null;
|
||||
ScheduleDate: string;
|
||||
cutOffTime: string | null;
|
||||
periodEnd: string | null;
|
||||
periodStart: string | null;
|
||||
}
|
||||
|
||||
const Schedule = () => {
|
||||
const { isTracking } = useOutletContext<TrackingState>();
|
||||
|
||||
const [rows, setRows] = useState<ScheduleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// const [lastUpdated, setLastUpdated] = useState<DateTime | null>(null);
|
||||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
|
||||
|
||||
/** Format: Only Date */
|
||||
const formatOnlyDate = (input: string | null) => {
|
||||
if (!input) return "-";
|
||||
return DateTime.fromISO(input).toLocaleString(DateTime.DATE_MED);
|
||||
};
|
||||
|
||||
/** Format: Date + Time */
|
||||
const formatDateTime = (input: string | null) => {
|
||||
if (!input) return "-";
|
||||
return DateTime.fromISO(input).toLocaleString(DateTime.DATETIME_MED);
|
||||
};
|
||||
|
||||
/** Fetch Data */
|
||||
const fetchSchedules = async () => {
|
||||
setRefreshing(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await window.electron.getSchedules();
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.message || "Failed to load schedules");
|
||||
return;
|
||||
}
|
||||
|
||||
setRows(res.result || []);
|
||||
setLastRefreshed(new Date());
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Unexpected error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Refresh Button */
|
||||
const handleRefresh = async () => {
|
||||
|
||||
await fetchSchedules();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, []);
|
||||
|
||||
/** Determine Active Period */
|
||||
const isActiveRow = (row: ScheduleItem) => {
|
||||
if (!row.periodStart || !row.periodEnd) return false;
|
||||
|
||||
const now = DateTime.local();
|
||||
const start = DateTime.fromISO(row.periodStart).startOf("day");
|
||||
const end = DateTime.fromISO(row.periodEnd).endOf("day");
|
||||
|
||||
return now >= start && now <= end;
|
||||
};
|
||||
|
||||
const activeSchedule = rows.find((r) => isActiveRow(r));
|
||||
|
||||
/** Skeleton Rows */
|
||||
const renderSkeletonRows = () =>
|
||||
Array.from({ length: 5 }).map((_, idx) => (
|
||||
<tr key={`sk-${idx}`}>
|
||||
<td><div className="skeleton h-4 w-6"></div></td>
|
||||
<td><div className="skeleton h-4 w-32"></div></td>
|
||||
<td><div className="skeleton h-4 w-28"></div></td>
|
||||
<td><div className="skeleton h-4 w-20"></div></td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const formatLastRefreshed = () => {
|
||||
if (!lastRefreshed) return "Never";
|
||||
|
||||
return DateTime.fromJSDate(lastRefreshed)
|
||||
.toLocal()
|
||||
.toFormat("HH:mm:ss"); // Only time
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex flex-col gap-2 overflow-hidden"
|
||||
style={{
|
||||
height: isTracking ? "calc(100vh - 80px)" : "calc(100vh - 24px)",
|
||||
}}
|
||||
>
|
||||
{/* HEADER */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-md font-bold">Schedules</h2>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{activeSchedule && (
|
||||
<div className="px-2 py-1 bg-primary text-primary-content rounded-lg text-xs">
|
||||
Pay Date: <b>{formatOnlyDate(activeSchedule.ScheduleDate)}</b>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TABLE WRAPPER */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-box border border-base-content/10 bg-base-100 shadow-md no-scrollbar"
|
||||
style={{
|
||||
height: isTracking ? "calc(100vh - 180px)" : "calc(100vh - 130px)",
|
||||
}}
|
||||
>
|
||||
{/* Loading */}
|
||||
{loading ? (
|
||||
<table className="table w-full text-[12px]">
|
||||
<tbody>{renderSkeletonRows()}</tbody>
|
||||
</table>
|
||||
) : error ? (
|
||||
<div className="text-center text-red-500 py-8">{error}</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-center py-8 text-base-content/50">
|
||||
No schedules found
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto no-scrollbar">
|
||||
<table className="table w-full text-[12px] min-w-[600px]">
|
||||
<thead className="sticky top-0 bg-base-200 z-10">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Period</th>
|
||||
<th>Cutoff Time</th>
|
||||
<th>Pay Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={
|
||||
isActiveRow(row)
|
||||
? "bg-primary text-primary-content font-semibold"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<td>{index + 1}</td>
|
||||
<td>
|
||||
{formatOnlyDate(row.periodStart)} →{" "}
|
||||
{formatOnlyDate(row.periodEnd)}
|
||||
</td>
|
||||
<td>{formatDateTime(row.cutOffTime)}</td>
|
||||
<td>{formatOnlyDate(row.ScheduleDate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{refreshing && (
|
||||
<tr>
|
||||
<td colSpan={4} className="bg-base-200/40 text-center py-2">
|
||||
<Loader2 className="animate-spin inline mr-2" size={16} />
|
||||
Refreshing...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div className="flex justify-between items-center text-xs text-base-content/60 mt-2">
|
||||
<div>{rows.length} schedules found</div>
|
||||
|
||||
|
||||
<div className="">
|
||||
{lastRefreshed &&
|
||||
`Last updated: ${formatLastRefreshed()}`}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="ms-2 btn btn-xs btn-circle bg-warning"
|
||||
title="Refresh schedule"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 size={14} className="animate-spin text-base-content" />
|
||||
) : (
|
||||
<RefreshCw size={14} className="text-base-content" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Schedule;
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
// src/ui/pages/Settings/ChangePasswordModal.tsx
|
||||
import React, { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useToast } from "../../helper/ToastContext";
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showOld, setShowOld] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast("New passwords do not match!" , "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
oldPassword,
|
||||
password: newPassword,
|
||||
cPassword: confirmPassword,
|
||||
};
|
||||
|
||||
// 🔗 Call the Electron bridge function
|
||||
const response = await window.electron.changePassword(payload);
|
||||
|
||||
if (response.success) {
|
||||
showToast("Password changed successfully!" , "success");
|
||||
setOldPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
onClose();
|
||||
} else {
|
||||
showToast((response.message || "Failed to change password.") , "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Password change error:", error);
|
||||
showToast("Error changing password." , "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<dialog open className="modal modal-open">
|
||||
<div className="modal-box w-96">
|
||||
<h3 className="font-bold text-lg mb-3">Change Password</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* Old Password */}
|
||||
<div>
|
||||
<label className="label text-xs font-medium">Current Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showOld ? "text" : "password"}
|
||||
className="input input-bordered w-full pr-10"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-3 text-gray-500"
|
||||
onClick={() => setShowOld(!showOld)}
|
||||
>
|
||||
{showOld ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* New Password */}
|
||||
<div>
|
||||
<label className="label text-xs font-medium">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
className="input input-bordered w-full pr-10"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-3 text-gray-500"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
>
|
||||
{showNew ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="label text-xs font-medium">Confirm New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
className="input input-bordered w-full pr-10"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-3 text-gray-500"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
>
|
||||
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`btn btn-sm btn-primary ${loading ? "loading" : ""}`}
|
||||
>
|
||||
{loading ? "Updating..." : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="dialog"
|
||||
className="modal-backdrop"
|
||||
onClick={!loading ? onClose : undefined}
|
||||
>
|
||||
<button disabled={loading}>close</button>
|
||||
</form>
|
||||
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordModal;
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "../../../store";
|
||||
import { setProfile } from "../../../store/profileSlice";
|
||||
|
||||
interface ChangePaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ChangePaymentModal: React.FC<ChangePaymentModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const { profile } = useSelector((state: RootState) => state.profile);
|
||||
|
||||
const [method, setMethod] = useState(profile?.paymentMethod || "");
|
||||
const [wiseEmail, setWiseEmail] = useState(profile?.wiseEmail || "");
|
||||
const [veemAccount, setVeemAccount] = useState(profile?.veemAccount || "");
|
||||
const [payoneerAccount, setPayoneerAccount] = useState(profile?.payoneerAccount || "");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMethod(profile?.paymentMethod || "");
|
||||
setWiseEmail(profile?.wiseEmail || "");
|
||||
setVeemAccount(profile?.veemAccount || "");
|
||||
setPayoneerAccount(profile?.payoneerAccount || "")
|
||||
setError("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
|
||||
// Validation
|
||||
if (!method) {
|
||||
return setError("Please select a payment method.");
|
||||
}
|
||||
|
||||
if (method === "wise" && !wiseEmail.trim()) {
|
||||
return setError("Wise email is required.");
|
||||
}
|
||||
|
||||
if (method === "veem" && !veemAccount.trim()) {
|
||||
return setError("Veem email is required.");
|
||||
}
|
||||
|
||||
if (method === "payoneeraccount" && !payoneerAccount.trim()) {
|
||||
return setError("Payoneer email is required.");
|
||||
}
|
||||
|
||||
if ((method === "wise" || "veem" || "payoneeraccount") && !/^\S+@\S+\.\S+$/.test(wiseEmail)) {
|
||||
return setError("Enter a valid email.");
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Prepare data for API
|
||||
const updateData: any = {
|
||||
paymentMethod: method
|
||||
};
|
||||
|
||||
// Add appropriate field based on selection
|
||||
if (method === "wise") {
|
||||
updateData.wiseEmail = wiseEmail.trim();
|
||||
} else if (method === "veem") {
|
||||
updateData.veemAccount = veemAccount.trim();
|
||||
}else if (method === "payoneeraccount") {
|
||||
updateData.payoneerAccount = payoneerAccount.trim();
|
||||
}
|
||||
|
||||
console.log("Sending to electron:", updateData);
|
||||
|
||||
// Call Electron API
|
||||
const result = await window.electron.updatePayment(updateData);
|
||||
|
||||
console.log(result)
|
||||
|
||||
if (result.success) {
|
||||
// Prepare data for Redux update (clear unused field)
|
||||
const reduxUpdateData: any = {
|
||||
paymentMethod: method
|
||||
};
|
||||
|
||||
if (method === "wise") {
|
||||
reduxUpdateData.wiseEmail = wiseEmail.trim();
|
||||
reduxUpdateData.veemAccount = ""; // Clear veem
|
||||
} else if (method === "veem") {
|
||||
reduxUpdateData.veemAccount = veemAccount.trim();
|
||||
reduxUpdateData.wiseEmail = ""; // Clear wise
|
||||
}
|
||||
|
||||
// Update Redux store
|
||||
dispatch(setProfile(result.data));
|
||||
|
||||
// Show success message if needed
|
||||
// toast.success("Payment method updated successfully");
|
||||
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.message || "Failed to update payment info.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to update payment info. Try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog open className="modal modal-open">
|
||||
<div className="modal-box w-96">
|
||||
|
||||
<h3 className="font-bold text-lg mb-4 flex items-center gap-2">
|
||||
<CreditCard size={18} /> Change Payment Method
|
||||
</h3>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="alert alert-error py-2 text-xs mb-3">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown */}
|
||||
<div className="form-control mb-4">
|
||||
<label className="label">
|
||||
<span className="text-sm font-medium">Payment Method</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
className="select select-bordered select-sm"
|
||||
value={method}
|
||||
onChange={(e) => setMethod(e.target.value)}
|
||||
>
|
||||
<option value="">Select method</option>
|
||||
<option value="wise">Wise</option>
|
||||
<option value="veem">Veem</option>
|
||||
<option value="payoneeraccount">Payoneer account</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Wise Email */}
|
||||
{method === "wise" && (
|
||||
<div className="form-control mb-4">
|
||||
<label className="label">
|
||||
<span className="text-sm font-medium">Wise Email</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input input-bordered input-sm"
|
||||
placeholder="Enter Wise email"
|
||||
value={wiseEmail}
|
||||
onChange={(e) => setWiseEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Veem Account */}
|
||||
{method === "veem" && (
|
||||
<div className="form-control mb-4">
|
||||
<label className="label">
|
||||
<span className="text-sm font-medium">Veem Account Number</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm"
|
||||
placeholder="Enter Veem account"
|
||||
value={veemAccount}
|
||||
onChange={(e) => setVeemAccount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{method === "payoneeraccount" && (
|
||||
<div className="form-control mb-4">
|
||||
<label className="label">
|
||||
<span className="text-sm font-medium">Payoneer Account Number</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm"
|
||||
placeholder="Enter Payoneer account"
|
||||
value={payoneerAccount}
|
||||
onChange={(e) => setPayoneerAccount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-sm btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn btn-sm btn-primary ${loading ? "loading" : ""}`}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Updating..." : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePaymentModal;
|
||||
|
|
@ -1,107 +1,161 @@
|
|||
import React from "react";
|
||||
import { User, Mail, Lock, Edit2, Shield, CalendarDays } from "lucide-react";
|
||||
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
employeeId: string;
|
||||
joinDate: string;
|
||||
department: string;
|
||||
profileImage?: string;
|
||||
}
|
||||
|
||||
const userData: UserProfile = {
|
||||
name: "Jai Pandey",
|
||||
email: "jai.pandey@vatechcorp.com",
|
||||
role: "Frontend Developer",
|
||||
employeeId: "EMP-1023",
|
||||
joinDate: "2022-07-15",
|
||||
department: "Engineering",
|
||||
profileImage: "https://ui-avatars.com/api/?name=Jai+Pandey&background=random",
|
||||
};
|
||||
import React, { useState } from "react";
|
||||
import { User, Mail, Phone, Calendar, MapPin, CreditCard, Globe, Lock, SquarePen } from "lucide-react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../../store";
|
||||
import ChangePasswordModal from "./ChangePasswordModal";
|
||||
import ChangePaymentModal from "./SettingModal/ChangePaymentModal";
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { profile } = useSelector((state: RootState) => state.profile);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [showPaymentModal,setShowPaymentModal] = useState(false)
|
||||
|
||||
const fullName = `${profile?.firstName || ""} ${profile?.lastName || ""}`.trim();
|
||||
|
||||
const maskLast5 = (value: string) => {
|
||||
if (!value) return "N/A";
|
||||
const str = value.toString();
|
||||
const last5 = str.slice(-5);
|
||||
const masked = "X".repeat(str.length - 5) + last5;
|
||||
return masked;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-h-screen">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-md font-bold flex items-center gap-2">
|
||||
Profile Settings
|
||||
<User size={18} /> Profile Settings
|
||||
</h2>
|
||||
<button className="btn btn-sm btn-primary gap-1">
|
||||
<Edit2 size={16} /> Edit Profile
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="btn btn-sm btn-outline btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Lock size={14} /> Change Password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Profile Section */}
|
||||
<div className="card bg-base-100 border border-base-300 shadow-sm p-4 flex flex-col md:flex-row gap-4">
|
||||
{/* Profile Picture */}
|
||||
<div className="flex flex-col items-center md:w-1/3">
|
||||
<div className="avatar">
|
||||
<div className="w-28 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 flex justify-center items-center">
|
||||
<User size={60} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold">{userData.name}</h3>
|
||||
<p className="text-sm text-base-content/70">{userData.role}</p>
|
||||
</div>
|
||||
|
||||
<div className="divider md:divider-horizontal" />
|
||||
|
||||
{/* Details Section */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 flex-1">
|
||||
<div className="p-2 rounded-md bg-base-200/50">
|
||||
<p className="text-xs text-base-content/70 flex items-center gap-2">
|
||||
<Mail size={14} /> Email
|
||||
</p>
|
||||
<p className="text-sm font-medium">{userData.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded-md bg-base-200/50">
|
||||
<p className="text-xs text-base-content/70 flex items-center gap-2">
|
||||
<Shield size={14} /> Role
|
||||
</p>
|
||||
<p className="text-sm font-medium">{userData.role}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded-md bg-base-200/50">
|
||||
<p className="text-xs text-base-content/70 flex items-center gap-2">
|
||||
<Lock size={14} /> Employee ID
|
||||
</p>
|
||||
<p className="text-sm font-medium">{userData.employeeId}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded-md bg-base-200/50">
|
||||
<p className="text-xs text-base-content/70 flex items-center gap-2">
|
||||
<CalendarDays size={14} /> Joining Date
|
||||
</p>
|
||||
<p className="text-sm font-medium">{userData.joinDate}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded-md bg-base-200/50 sm:col-span-2">
|
||||
<p className="text-xs text-base-content/70 flex items-center gap-2">
|
||||
<User size={14} /> Department
|
||||
</p>
|
||||
<p className="text-sm font-medium">{userData.department}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Settings */}
|
||||
{/* Profile Card */}
|
||||
<div className="card bg-base-100 border border-base-300 shadow-sm p-4">
|
||||
<h3 className="text-md font-semibold mb-3">Account Security</h3>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-t border-base-300 pt-3">
|
||||
<p className="text-sm flex items-center gap-2">
|
||||
<Lock size={16} /> Password last changed on{" "}
|
||||
<span className="font-medium text-base-content">Aug 12, 2024</span>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Compact Avatar Section */}
|
||||
<div className="flex flex-col items-center md:items-start md:w-48 shrink-0">
|
||||
<div className="avatar">
|
||||
<div className="w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-1 flex justify-center items-center bg-base-200">
|
||||
<User size={40} className="text-base-content/50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-center md:text-left">
|
||||
<h3 className="text-sm font-semibold">{fullName || "N/A"}</h3>
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 justify-center md:justify-start mt-0.5">
|
||||
<Mail size={10} /> {profile?.email || "No Email"}
|
||||
</p>
|
||||
<button className="btn btn-sm btn-outline btn-primary">
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider md:divider-horizontal my-0 md:mx-2" />
|
||||
|
||||
{/* Compact Details Grid */}
|
||||
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 gap-2.5">
|
||||
{/* Basic Info */}
|
||||
<div className="p-2 rounded bg-base-200/40">
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5">
|
||||
<Calendar size={11} /> DOB
|
||||
</p>
|
||||
<p className="text-xs font-medium">{profile?.dob || "N/A"}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded bg-base-200/40">
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5">
|
||||
🎂 Age
|
||||
</p>
|
||||
<p className="text-xs font-medium">{profile?.age || "N/A"}</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="p-2 rounded bg-base-200/40">
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5">
|
||||
<Phone size={11} /> Phone
|
||||
</p>
|
||||
<p className="text-xs font-medium truncate" title={profile?.phone || "N/A"}>
|
||||
{profile?.phone || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Payment Info */}
|
||||
<div
|
||||
className=" p-2 rounded bg-base-200/40 cursor-pointer hover:text-primary transition-all duration-200 ease-in-out hover:scale-[1.02] active:scale-[0.98] " onClick={() => setShowPaymentModal(true)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5">
|
||||
<CreditCard size={11} /> Payment
|
||||
</p>
|
||||
<p className="text-xs font-medium capitalize truncate">
|
||||
{profile?.paymentMethod || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<span>
|
||||
<SquarePen size={15} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{profile?.veemAccount && (
|
||||
<div className="p-2 rounded bg-base-200/40">
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5">
|
||||
🏦 Veem Email
|
||||
</p>
|
||||
<p className="text-xs font-medium truncate" title={profile?.veemAccount || "N/A"}>
|
||||
{profile?.veemAccount || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile?.wiseEmail&& (
|
||||
<div className="p-2 rounded bg-base-200/40">
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5 ">
|
||||
🏦 Wise Email
|
||||
</p>
|
||||
<p className="text-xs font-medium overflow-auto no-scrollbar" title={profile?.wiseEmail || "N/A"}>
|
||||
{profile?.wiseEmail || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile?.payoneerAccount && (
|
||||
<div className="p-2 rounded bg-base-200/40">
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5 ">
|
||||
🏦 Payoneer Account
|
||||
</p>
|
||||
<p className="text-xs font-medium overflow-auto no-scrollbar" title={profile?.payoneerAccount || "N/A"}>
|
||||
{profile?.payoneerAccount || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Location - Full Width */}
|
||||
<div className="p-2 rounded bg-base-200/40 col-span-2 sm:col-span-3">
|
||||
<p className="text-xs text-base-content/60 flex items-center gap-1 mb-0.5">
|
||||
<MapPin size={11} /> Address
|
||||
</p>
|
||||
<p className="text-xs font-medium line-clamp-2">
|
||||
{[profile?.address, profile?.city, profile?.state, profile?.country]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Modal */}
|
||||
<ChangePasswordModal isOpen={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
<ChangePaymentModal isOpen={showPaymentModal} onClose={() => setShowPaymentModal(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface TabActivity {
|
||||
title: string;
|
||||
appName: string;
|
||||
from: string;
|
||||
to: string;
|
||||
durationMs: number;
|
||||
durationFormatted: string;
|
||||
}
|
||||
|
||||
interface ProjectSession {
|
||||
from: string;
|
||||
to: string;
|
||||
totalSessionTime: string;
|
||||
tabs: TabActivity[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectName: string;
|
||||
|
||||
logs: ProjectSession[];
|
||||
loading: boolean;
|
||||
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setLogs: React.Dispatch<React.SetStateAction<ProjectSession[]>>;
|
||||
|
||||
projectID: string | null;
|
||||
}
|
||||
|
||||
|
||||
const ProjectLogsModal: React.FC<Props> = ({ isOpen, onClose, projectName, logs, loading, setLoading,
|
||||
setLogs,
|
||||
projectID, }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const isFirstRender = React.useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// ⛔ skip first render (parent already fetched)
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ fetch only when date changes
|
||||
fetchLogs(selectedDate);
|
||||
}, [selectedDate]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
isFirstRender.current = true;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
|
||||
|
||||
const getTimeDifference = (from: string, to: string) => {
|
||||
const start = DateTime.fromISO(from);
|
||||
const end = DateTime.fromISO(to);
|
||||
|
||||
const diff = end.diff(start, ["hours", "minutes", "seconds"]);
|
||||
|
||||
return {
|
||||
hours: diff.hours,
|
||||
minutes: diff.minutes,
|
||||
seconds: Math.floor(diff.seconds),
|
||||
formatted: `${diff.hours}h ${diff.minutes}m ${Math.floor(diff.seconds)}s`
|
||||
};
|
||||
};
|
||||
|
||||
const getDayRange = (date: Date) => {
|
||||
const d = DateTime.fromJSDate(date);
|
||||
|
||||
return {
|
||||
fromDate: d.toFormat("yyyy-MM-dd"),
|
||||
toDate: d.toFormat("yyyy-MM-dd"),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const handlePrevDay = () => {
|
||||
const prev = new Date(selectedDate);
|
||||
prev.setDate(prev.getDate() - 1);
|
||||
|
||||
setSelectedDate(prev); // triggers useEffect
|
||||
};
|
||||
|
||||
|
||||
const handleNextDay = () => {
|
||||
const next = new Date(selectedDate);
|
||||
next.setDate(next.getDate() + 1);
|
||||
|
||||
setSelectedDate(next); // triggers useEffect
|
||||
};
|
||||
|
||||
|
||||
const fetchLogs = async (date: Date) => {
|
||||
if (!projectID) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// HERE
|
||||
const { fromDate, toDate } = getDayRange(date);
|
||||
|
||||
const res = await window.electron.getProjectLogs(
|
||||
projectID,
|
||||
fromDate,
|
||||
toDate
|
||||
);
|
||||
|
||||
if (res?.success && Array.isArray(res.data)) {
|
||||
setLogs(res.data[0]?.sessions || []);
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch logs", err);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box max-h-[97vh] max-w-4xl bg-base-100 flex flex-col">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="font-bold text-xs">
|
||||
🗂️ Project Logs — <span className="text-primary">{projectName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
|
||||
<button className="btn btn-xs btn-primary p-1" onClick={handlePrevDay}><ChevronLeft className="" size={12}/></button>
|
||||
|
||||
<span className="font-semibold">
|
||||
{DateTime.fromJSDate(selectedDate).toFormat("dd LLL yyyy")}
|
||||
</span>
|
||||
|
||||
<button className="btn btn-xs btn-primary p-1" onClick={handleNextDay}><ChevronRight className="" size={12}/></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center flex-grow">
|
||||
<span className="loading loading-spinner text-primary"></span>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<p className="text-center text-gray-500 flex-grow">No logs available.</p>
|
||||
) : (
|
||||
<div className="overflow-y-auto flex-grow space-y-4 no-scrollbar">
|
||||
{logs.map((session, idx) => (
|
||||
<div key={idx} className="border border-base-300 overflow-hidden">
|
||||
<div className="bg-base-200 p-2 font-semibold flex justify-between items-center">
|
||||
<span className="text-[12px]">
|
||||
🕒 Session {idx + 1} — {getTimeDifference(
|
||||
new Date(session.from).toISOString(),
|
||||
new Date(session.to).toISOString()
|
||||
).formatted}
|
||||
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(session.from).toLocaleTimeString()} -{" "}
|
||||
{new Date(session.to).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full font-semibold text-[12px]">
|
||||
<thead className="sticky top-0 bg-base-300 z-10">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Title</th>
|
||||
<th>Time</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{session.tabs.map((tab, tabIdx) => (
|
||||
<tr key={tabIdx} className="text-[10px]">
|
||||
<td>{tabIdx + 1}</td>
|
||||
<td className="truncate max-w-[150px]" title={tab.title}>
|
||||
{tab.title} <br />
|
||||
<span className="text-slate-500">{tab.appName}</span>
|
||||
</td>
|
||||
<td><span className="text-green-500">{new Date(tab.from).toLocaleTimeString()}</span> <br /><span className="text-red-600">{new Date(tab.to).toLocaleTimeString()}</span></td>
|
||||
<td>{tab.durationFormatted}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-action mt-2">
|
||||
<button className="btn btn-outline btn-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectLogsModal;
|
||||
|
||||
|
|
@ -1,5 +1,22 @@
|
|||
import React from "react";
|
||||
import { CalendarDays, Clock, FileText, CheckCircle, XCircle } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
CalendarDays,
|
||||
Clock,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../../store";
|
||||
import ProjectLogsModal from "./ProjectLogsModal";
|
||||
import { TrackingState } from "../../layout/DashboardLayout";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
|
||||
interface TimesheetEntry {
|
||||
id: number;
|
||||
|
|
@ -18,104 +35,273 @@ const timesheetData: TimesheetEntry[] = [
|
|||
{ id: 5, date: "2025-11-01", project: "AI Model Trainer", hours: 8, status: "Approved", remarks: "Normal day" },
|
||||
];
|
||||
|
||||
interface ProjectSession {
|
||||
from: string;
|
||||
to: string;
|
||||
totalSessionTime: string;
|
||||
tabs: TabActivity[];
|
||||
}
|
||||
|
||||
interface Project {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
activityStatus: "ACTIVE" | "AWAY";
|
||||
totalWorkTime: string;
|
||||
totalWorkMs: number;
|
||||
totalLogs: number;
|
||||
status: string
|
||||
}
|
||||
|
||||
const Timesheets: React.FC = () => {
|
||||
const { isTracking } = useOutletContext<TrackingState>();
|
||||
const totalHours = timesheetData.reduce((acc, t) => acc + t.hours, 0);
|
||||
const approvedCount = timesheetData.filter((t) => t.status === "Approved").length;
|
||||
|
||||
const { windowLogs } = useSelector((state: RootState) => state.window);
|
||||
|
||||
|
||||
console.log(" Timesheet in component:", windowLogs);
|
||||
const [projectsData, setProjectsData] = useState<Project[]>([]);
|
||||
const [logs, setLogs] = useState<ProjectSession[]>([]);
|
||||
const [selectedProjectLogId, setSelectedProjectLogId] = useState<string | null>(null);
|
||||
const [selectedProjectName, setSelectedProjectName] = useState<string>("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [tick, setTick] = useState(0);
|
||||
const { selectedProject } = useSelector((state: any) => state.projects);
|
||||
const selectedProjectId = selectedProject?.projectId ?? null;
|
||||
|
||||
const activeProjects = projectsData.filter(p => p.status === "ACTIVE");
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTick((t) => t + 1); // force re-render every second
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const formatLastRefreshed = () => {
|
||||
if (!lastRefreshed) return "Never";
|
||||
|
||||
return DateTime.fromJSDate(lastRefreshed)
|
||||
.toLocal()
|
||||
.toFormat("HH:mm:ss"); // Only time
|
||||
};
|
||||
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setInitialLoading(true);
|
||||
try {
|
||||
console.log("📡 Fetching projects...");
|
||||
const res = await window.electron.getProjectList();
|
||||
console.log("✅ Project list response:", res);
|
||||
|
||||
if (res?.success && Array.isArray(res.data)) {
|
||||
// Store project list
|
||||
setProjectsData(res.data);
|
||||
setLastRefreshed(new Date());
|
||||
} else {
|
||||
console.warn(" Unexpected response:", res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching projects:", err);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchProjects();
|
||||
};
|
||||
|
||||
const handleViewLogs = async (projectID: string, projectName: string) => {
|
||||
try {
|
||||
setModalOpen(true);
|
||||
setLoading(true);
|
||||
setSelectedProjectLogId(projectID);
|
||||
setSelectedProjectName(projectName);
|
||||
|
||||
console.log("projectID", projectID)
|
||||
|
||||
const res = await window.electron.getProjectLogs(projectID);
|
||||
console.log("📦 Full Logs:", res);
|
||||
|
||||
if (res?.success && Array.isArray(res.data)) {
|
||||
setLogs(res.data[0].sessions || []);
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching logs:", error);
|
||||
setModalOpen(false)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const formatWorkTime = (time: string) => {
|
||||
if (!time) return "0h 0m";
|
||||
|
||||
// Example input: "0h 2m 29s"
|
||||
const parts = time.split(" ");
|
||||
|
||||
// Always return first two parts: ["0h", "2m"]
|
||||
return parts.slice(0, 2).join(" ");
|
||||
};
|
||||
|
||||
// Skeleton loader rows
|
||||
const renderSkeletonRows = () => {
|
||||
return Array.from({ length: 5 }).map((_, index) => (
|
||||
<tr key={`skeleton-${index}`} className="hover:bg-base-200">
|
||||
<td>
|
||||
<div className="skeleton h-4 w-6 bg-base-300"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="skeleton h-4 w-32 bg-base-300"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="skeleton h-4 w-16 bg-base-300"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="skeleton h-8 w-8 rounded bg-base-300"></div>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-h-screen">
|
||||
<main
|
||||
className="flex flex-col gap-2 overflow-hidden"
|
||||
style={{
|
||||
height: isTracking ? "calc(100vh - 80px)" : "calc(100vh - 24px)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-none mb-2 flex justify-between items-center">
|
||||
<h2 className="text-md font-bold">Timesheets</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div className="card-body py-3 px-4 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-base-content/70">Total Logged Hours</p>
|
||||
<h3 className="text-lg font-semibold">{totalHours} hrs</h3>
|
||||
</div>
|
||||
<Clock size={20} className="text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div className="card-body py-3 px-4 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-base-content/70">Approved Days</p>
|
||||
<h3 className="text-lg font-semibold">{approvedCount}</h3>
|
||||
</div>
|
||||
<CheckCircle size={20} className="text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-100 border border-base-300 shadow-sm">
|
||||
<div className="card-body py-3 px-4 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-base-content/70">Pending Days</p>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{timesheetData.length - approvedCount}
|
||||
</h3>
|
||||
</div>
|
||||
<FileText size={20} className="text-warning" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timesheet Table */}
|
||||
<div className="overflow-y-auto rounded-box border border-base-content/10 bg-base-100 shadow-md max-h-[60vh]">
|
||||
<table className="table table-zebra w-full text-sm">
|
||||
<thead className="sticky top-0 bg-base-200 z-10 text-sm">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-box border border-base-content/10 bg-base-100 shadow-md"
|
||||
style={{
|
||||
height: isTracking ? "calc(100vh - 180px)" : "calc(100vh - 130px)",
|
||||
}}
|
||||
>
|
||||
<table className="table table-zebra w-full text-[12px]">
|
||||
<thead className="sticky top-0 bg-base-200 z-10">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Date</th>
|
||||
<th>Project</th>
|
||||
<th>Hours</th>
|
||||
<th>Status</th>
|
||||
<th>Remarks</th>
|
||||
<th>Logs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timesheetData.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.id}</td>
|
||||
<td className="whitespace-nowrap">{entry.date}</td>
|
||||
<td>{entry.project}</td>
|
||||
<td>{entry.hours} hrs</td>
|
||||
<td>
|
||||
<div
|
||||
className={`badge badge-sm ${
|
||||
entry.status === "Approved"
|
||||
? "badge-success"
|
||||
: entry.status === "Pending"
|
||||
? "badge-warning"
|
||||
: "badge-error"
|
||||
}`}
|
||||
{initialLoading ? (
|
||||
// Show skeleton while initially loading
|
||||
renderSkeletonRows()
|
||||
) : refreshing && projectsData.length === 0 ? (
|
||||
// Show skeleton while refreshing with empty data
|
||||
renderSkeletonRows()
|
||||
) : (
|
||||
// Show actual data
|
||||
projectsData.map((entry, index) => (
|
||||
<tr
|
||||
key={entry.projectId}
|
||||
className={`${entry.status === "INACTIVE" && "hidden"}`}
|
||||
>
|
||||
{entry.status}
|
||||
<td>{index + 1}</td>
|
||||
<td>{entry.projectName}</td>
|
||||
<td>{formatWorkTime(entry.totalWorkTime)}</td>
|
||||
<td>
|
||||
<button
|
||||
disabled={(formatWorkTime(entry.totalWorkTime) === "0h 0m" || (isTracking && entry.projectId === selectedProjectId))}
|
||||
onClick={() =>
|
||||
handleViewLogs(entry.projectId, entry.projectName)
|
||||
}
|
||||
className="btn btn-ghost btn-xs"
|
||||
>
|
||||
{(formatWorkTime(entry.totalWorkTime) === "0h 0m" || (isTracking && entry.projectId === selectedProjectId)) ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* {refreshing && projectsData.length > 0 && (
|
||||
// Show a single skeleton row at the top while refreshing with existing data
|
||||
<tr>
|
||||
<td colSpan={4} className="bg-base-200/50">
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span className="ml-2 text-sm">Refreshing...</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{entry.remarks || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
)} */}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer Summary */}
|
||||
<div className="flex justify-between items-center text-xs mt-2">
|
||||
<span className="text-base-content/60">
|
||||
Showing {timesheetData.length} entries
|
||||
</span>
|
||||
<span className="text-base-content/60">
|
||||
Last updated: {new Date().toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex-none flex justify-between items-center text-xs mt-2">
|
||||
<div className="text-base-content/60">
|
||||
{activeProjects.length} project{activeProjects.length !== 1 ? 's' : ''} shown
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{lastRefreshed && (
|
||||
<div className="text-xs text-base-content/60">
|
||||
Last updated: {formatLastRefreshed()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="btn btn-xs btn-circle bg-warning"
|
||||
title="Refresh projects"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 size={15} className="animate-spin text-base-content" />
|
||||
) : (
|
||||
<RefreshCw size={15} className="text-base-content" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectLogsModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
projectName={selectedProjectName}
|
||||
logs={logs}
|
||||
loading={loading}
|
||||
setLoading={setLoading}
|
||||
setLogs={setLogs}
|
||||
projectID={selectedProjectLogId}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import React from 'react'
|
||||
|
||||
const Vpn = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="max-h-screen">
|
||||
<div className="w-full mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className=" ">
|
||||
<h1 className="text-mb font-bold mb-2">VPN Servers</h1>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-box border border-base-content/5 bg-base-100 text-sm">
|
||||
<table className="table">
|
||||
{/* head */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Server name</th>
|
||||
<th>Connect</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* row 1 */}
|
||||
<tr>
|
||||
<th>1</th>
|
||||
<td>Los Angeles [Fixed]</td>
|
||||
<td><button className="btn btn-xs btn-secondary">Connect</button></td>
|
||||
|
||||
</tr>
|
||||
{/* row 2 */}
|
||||
<tr>
|
||||
<th>2</th>
|
||||
<td>Dallas 1</td>
|
||||
<td><button className="btn btn-xs btn-secondary">Connect</button></td>
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Vpn
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Shield, ShieldAlert, RefreshCw, Wifi, Power, X } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const VpnGuard: React.FC<Props> = ({ children }) => {
|
||||
|
||||
const VPN_API = "http://10.0.0.1";
|
||||
const DEFAULT_API = "http://dreadingly-unfighting-pandora.ngrok-free.dev"
|
||||
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [vpnConnected, setVpnConnected] = useState(true);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
|
||||
const checkVPN = async () => {
|
||||
try {
|
||||
setChecking(true);
|
||||
|
||||
const res = await window.electron?.checkRegion();
|
||||
console.log("VpnGuard", res);
|
||||
if (res.status === 200) {
|
||||
const baseRes = await window.electron.updateBaseURL(DEFAULT_API);
|
||||
setVpnConnected(true);
|
||||
} else {
|
||||
setVpnConnected(false);
|
||||
return;
|
||||
}
|
||||
// setVpnConnected(res?.success === true);
|
||||
} catch (error) {
|
||||
console.error("VPN check failed:", error);
|
||||
setVpnConnected(false);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
setIsRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkVPN();
|
||||
}, []);
|
||||
|
||||
const connectVPN = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await window.electron.connectVPN();
|
||||
console.log("connectVPN result → ", res);
|
||||
|
||||
// Only proceed if VPN actually connected
|
||||
if (res === "Windows VPN connected silently" || res === "VPN already connected and healthy") {
|
||||
|
||||
const vpnRes = await window.electron.updateBaseURL(VPN_API);
|
||||
console.log("vpnRes", vpnRes);
|
||||
|
||||
if (vpnRes) {
|
||||
// Run VPN check again AFTER URL changes
|
||||
await checkVPN();
|
||||
} else {
|
||||
console.warn("Base URL updated but health check failed");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
const vpnRes = await window.electron.updateBaseURL(VPN_API);
|
||||
console.log("vpnRes", vpnRes);
|
||||
|
||||
if (vpnRes) {
|
||||
|
||||
// Run VPN check again AFTER URL changes
|
||||
await checkVPN();
|
||||
} else {
|
||||
console.warn("Base URL updated but health check failed");
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const disconnectVPN = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await window.electron.disconnectVPN();;
|
||||
console.log("disconnectVPN →", res);
|
||||
|
||||
// Restore original base URL
|
||||
const baseRes = await window.electron.updateBaseURL(DEFAULT_API);
|
||||
console.log("baseRes disconnect →", baseRes);
|
||||
|
||||
setVpnConnected(true);
|
||||
|
||||
// await checkVPN();
|
||||
} catch (error) {
|
||||
console.log("Disconnect Error:", error);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Checking state
|
||||
if (checking && !isRetrying) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center items-center bg-base-200">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-24 h-24 border-4 border-primary opacity-20 rounded-full animate-ping"></div>
|
||||
</div>
|
||||
<div className="relative bg-base-100 rounded-full p-6 shadow-xl">
|
||||
<Shield className="w-12 h-12 text-primary animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">
|
||||
Verifying Connection
|
||||
</h2>
|
||||
<p className="text-base-content opacity-70">Checking VPN status...</p>
|
||||
<div className="flex justify-center mt-4 gap-1">
|
||||
<span className="loading loading-dots loading-md text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// VPN not connected state
|
||||
if (!vpnConnected) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center items-center bg-base-200 p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="card bg-base-100 shadow-2xl">
|
||||
<X className="mt-2 mr-2 ml-auto text-slate-300 hover:text-slate-400" onClick={() => setVpnConnected(true)} />
|
||||
<div className="card-body items-center text-center">
|
||||
|
||||
{/* Icon */}
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-20 h-20 border-4 border-error opacity-20 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<div className="relative bg-error bg-opacity-10 rounded-full p-4">
|
||||
<ShieldAlert className="w-12 h-12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="card-title text-3xl mb-3">VPN Required</h2>
|
||||
<p className="mb-6 opacity-80">
|
||||
Please connect to your company VPN to access this application.
|
||||
</p>
|
||||
|
||||
{/* Connect VPN */}
|
||||
<button
|
||||
className="btn btn-primary btn-wide gap-2"
|
||||
onClick={connectVPN}
|
||||
disabled={isRetrying || vpnConnected} // Disable if retrying OR already connected
|
||||
>
|
||||
{isRetrying ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
Checking Connection...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Connect VPN
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Disconnect VPN (Disabled when NOT connected) */}
|
||||
{/* <button
|
||||
className="btn btn-error btn-wide gap-2 mt-3"
|
||||
onClick={disconnectVPN}
|
||||
// disabled={!vpnConnected || checking} // Disable if not connected
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
Disconnect VPN
|
||||
</button> */}
|
||||
|
||||
<div className="divider"></div>
|
||||
<p className="text-xs opacity-60">
|
||||
Need help? Contact IT support at{" "}
|
||||
<a href="mailto:support@docva.com" className="link link-primary">
|
||||
support@docva.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default VpnGuard;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// src/store/index.ts
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import windowReducer from "./windowSlice";
|
||||
import projectsReducer from "./projectSlice";
|
||||
import userReducer from "./userSlice";
|
||||
import profileReducer from "./profileSlice";
|
||||
import logsReducer from './logsSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
window: windowReducer,
|
||||
projects: projectsReducer,
|
||||
user: userReducer,
|
||||
profile: profileReducer,
|
||||
logs: logsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
// store/logsSlice.ts
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface LogEntry {
|
||||
id: string; // Unique ID for each log entry
|
||||
title: string;
|
||||
appName: string;
|
||||
url: string | null;
|
||||
from: string; // UTC ISO string with .000 milliseconds
|
||||
to: string; // UTC ISO string with .000 milliseconds
|
||||
totalTime: number; // milliseconds
|
||||
}
|
||||
|
||||
export interface ProjectLogs {
|
||||
projectId: string;
|
||||
logId: string | null;
|
||||
logs: LogEntry[]; // All collected logs during tracking
|
||||
trackingStartTime: string | null;
|
||||
lastWindowTitle: string | null; // Track last window title
|
||||
lastWindowApp: string | null; // Track last window app
|
||||
}
|
||||
|
||||
interface LogsState {
|
||||
logsByProject: Record<string, ProjectLogs>;
|
||||
}
|
||||
|
||||
const initialState: LogsState = {
|
||||
logsByProject: {},
|
||||
};
|
||||
|
||||
// Helper function to get timestamp with .000 milliseconds
|
||||
const getTimestampWithZeroMs = () => {
|
||||
const now = new Date();
|
||||
// Format: YYYY-MM-DDTHH:mm:ss.000Z
|
||||
const isoString = now.toISOString();
|
||||
// Replace milliseconds with .000
|
||||
return isoString.replace(/\.\d{3}Z$/, '.000Z');
|
||||
};
|
||||
|
||||
// Helper function to format any date string to have .000 milliseconds
|
||||
const formatToZeroMs = (dateString: string): string => {
|
||||
if (dateString.endsWith('.000Z')) {
|
||||
return dateString; // Already formatted
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
const isoString = date.toISOString();
|
||||
return isoString.replace(/\.\d{3}Z$/, '.000Z');
|
||||
};
|
||||
|
||||
const logsSlice = createSlice({
|
||||
name: "logs",
|
||||
initialState,
|
||||
reducers: {
|
||||
// Initialize logs for a project
|
||||
initializeProjectLogs: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
projectId: string;
|
||||
logId: string;
|
||||
}>
|
||||
) => {
|
||||
const { projectId, logId } = action.payload;
|
||||
const nowUTC = getTimestampWithZeroMs();
|
||||
|
||||
// Always create fresh logs for new tracking session
|
||||
state.logsByProject[projectId] = {
|
||||
projectId,
|
||||
logId,
|
||||
logs: [], // Start with empty logs
|
||||
trackingStartTime: nowUTC,
|
||||
lastWindowTitle: null,
|
||||
lastWindowApp: null,
|
||||
};
|
||||
|
||||
console.log(`📝 Initialized logs for project ${projectId} with logId: ${logId}`);
|
||||
},
|
||||
|
||||
// Add or update window log
|
||||
updateWindowLog: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
projectId: string;
|
||||
title: string;
|
||||
appName: string;
|
||||
url?: string | null;
|
||||
}>
|
||||
) => {
|
||||
const { projectId, title, appName, url = null } = action.payload;
|
||||
const projectLogs = state.logsByProject[projectId];
|
||||
|
||||
if (!projectLogs) {
|
||||
console.warn(`Cannot update log: Project ${projectId} not initialized`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nowUTC = getTimestampWithZeroMs();
|
||||
const isSameWindow =
|
||||
projectLogs.lastWindowTitle === title &&
|
||||
projectLogs.lastWindowApp === appName;
|
||||
|
||||
// If same window as before, update the last log's end time
|
||||
if (isSameWindow && projectLogs.logs.length > 0) {
|
||||
const lastLog = projectLogs.logs[projectLogs.logs.length - 1];
|
||||
// Format the existing from time to ensure it has .000 milliseconds
|
||||
lastLog.from = formatToZeroMs(lastLog.from);
|
||||
lastLog.to = nowUTC;
|
||||
|
||||
// Calculate total time in milliseconds
|
||||
const startTime = new Date(lastLog.from).getTime();
|
||||
const endTime = new Date(lastLog.to).getTime();
|
||||
lastLog.totalTime = endTime - startTime;
|
||||
|
||||
// console.log(`⏱️ Updated existing log: ${title} (${lastLog.totalTime}ms)`);
|
||||
}
|
||||
// If new window or first window
|
||||
else {
|
||||
// If there was a previous window, ensure it's properly ended
|
||||
if (projectLogs.lastWindowTitle && projectLogs.logs.length > 0) {
|
||||
const lastLog = projectLogs.logs[projectLogs.logs.length - 1];
|
||||
// Format the existing from time to ensure it has .000 milliseconds
|
||||
lastLog.from = formatToZeroMs(lastLog.from);
|
||||
lastLog.to = nowUTC;
|
||||
|
||||
// Calculate total time in milliseconds
|
||||
const startTime = new Date(lastLog.from).getTime();
|
||||
const endTime = new Date(lastLog.to).getTime();
|
||||
lastLog.totalTime = endTime - startTime;
|
||||
|
||||
console.log(
|
||||
`✅ Finalized previous window: ${lastLog.title} (${lastLog.totalTime}ms)`
|
||||
);
|
||||
}
|
||||
|
||||
// Create new log entry with .000 milliseconds
|
||||
const newLog: LogEntry = {
|
||||
id: `log_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
title,
|
||||
appName,
|
||||
url,
|
||||
from: nowUTC,
|
||||
to: nowUTC, // Will be updated continuously
|
||||
totalTime: 0, // Starts at 0 (in milliseconds)
|
||||
};
|
||||
|
||||
projectLogs.logs.push(newLog);
|
||||
console.log(`📝 Started new window: ${title} on ${appName}`);
|
||||
}
|
||||
|
||||
// Update last window tracking
|
||||
projectLogs.lastWindowTitle = title;
|
||||
projectLogs.lastWindowApp = appName;
|
||||
},
|
||||
|
||||
// Clear project logs
|
||||
clearProjectLogs: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
projectId: string;
|
||||
}>
|
||||
) => {
|
||||
const { projectId } = action.payload;
|
||||
delete state.logsByProject[projectId];
|
||||
console.log(`🧹 Cleared logs for project ${projectId}`);
|
||||
},
|
||||
|
||||
// Clear all logs
|
||||
clearAllLogs: (state) => {
|
||||
state.logsByProject = {};
|
||||
console.log("🧹 Cleared all logs");
|
||||
},
|
||||
|
||||
// Debug: Get logs state
|
||||
debugLogsState: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
projectId: string;
|
||||
}>
|
||||
) => {
|
||||
const { projectId } = action.payload;
|
||||
const projectLogs = state.logsByProject[projectId];
|
||||
|
||||
if (!projectLogs) {
|
||||
console.log(`📋 No logs found for project ${projectId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Logs State for ${projectId}:`, {
|
||||
totalLogs: projectLogs.logs.length,
|
||||
lastWindow: projectLogs.lastWindowTitle,
|
||||
logs: projectLogs.logs.map((log) => ({
|
||||
title: log.title.substring(0, 40) + (log.title.length > 40 ? "..." : ""),
|
||||
appName: log.appName,
|
||||
from: log.from,
|
||||
to: log.to,
|
||||
durationMs: log.totalTime,
|
||||
durationSec: Math.round(log.totalTime / 1000),
|
||||
})),
|
||||
});
|
||||
},
|
||||
|
||||
// Optional: Add a reducer to fix all timestamps in existing logs
|
||||
fixAllTimestamps: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
projectId: string;
|
||||
}>
|
||||
) => {
|
||||
const { projectId } = action.payload;
|
||||
const projectLogs = state.logsByProject[projectId];
|
||||
|
||||
if (!projectLogs) {
|
||||
console.warn(`Cannot fix timestamps: Project ${projectId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix all timestamps in existing logs
|
||||
projectLogs.logs.forEach(log => {
|
||||
log.from = formatToZeroMs(log.from);
|
||||
log.to = formatToZeroMs(log.to);
|
||||
// Recalculate totalTime based on formatted timestamps
|
||||
const startTime = new Date(log.from).getTime();
|
||||
const endTime = new Date(log.to).getTime();
|
||||
log.totalTime = endTime - startTime;
|
||||
});
|
||||
|
||||
console.log(`🔧 Fixed timestamps for project ${projectId}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
initializeProjectLogs,
|
||||
updateWindowLog,
|
||||
clearProjectLogs,
|
||||
clearAllLogs,
|
||||
debugLogsState,
|
||||
fixAllTimestamps,
|
||||
} = logsSlice.actions;
|
||||
|
||||
export default logsSlice.reducer;
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface ProfileData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dob: string;
|
||||
age: string;
|
||||
address: string;
|
||||
bankName: string;
|
||||
accountHolderName: string;
|
||||
accountNumber: string;
|
||||
routingNumber: string;
|
||||
phone:string;
|
||||
skype:string;
|
||||
city:string;
|
||||
state:string;
|
||||
country:string;
|
||||
paymentMethod:string;
|
||||
payoneerAccount:string;
|
||||
veemAccount:string;
|
||||
wiseEmail:string
|
||||
_id: string;
|
||||
}
|
||||
|
||||
interface ProfileState {
|
||||
profile: ProfileData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ProfileState = {
|
||||
profile: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const profileSlice = createSlice({
|
||||
name: "profile",
|
||||
initialState,
|
||||
reducers: {
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
setProfile: (state, action: PayloadAction<ProfileData>) => {
|
||||
state.profile = action.payload;
|
||||
state.error = null;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
},
|
||||
clearProfile: (state) => {
|
||||
state.profile = null;
|
||||
state.error = null;
|
||||
state.loading = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLoading, setProfile, setError, clearProfile } = profileSlice.actions;
|
||||
export default profileSlice.reducer;
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface Project {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
totalProjects: number;
|
||||
activeProjects: number;
|
||||
awayProjects: number;
|
||||
totalWorkTime: string;
|
||||
totalWorkMs: number;
|
||||
vaName: string;
|
||||
vaId: string;
|
||||
}
|
||||
interface TrackingData {
|
||||
elapsedTime: number;
|
||||
startTime: number | null;
|
||||
endTime: number | null;
|
||||
isTracking: boolean;
|
||||
isPaused: boolean;
|
||||
pauseStartTime: number | null;
|
||||
totalPausedTime: number;
|
||||
accumulatedTime: number;
|
||||
}
|
||||
|
||||
interface ProjectsState {
|
||||
projectsData: Project[];
|
||||
projectSummary: ProjectSummary | null;
|
||||
selectedProject: Project | null;
|
||||
projectTrackingMap: Record<string, TrackingData>;
|
||||
}
|
||||
|
||||
const initialState: ProjectsState = {
|
||||
projectsData: [],
|
||||
projectSummary: null,
|
||||
selectedProject: null,
|
||||
projectTrackingMap: {},
|
||||
};
|
||||
|
||||
const projectsSlice = createSlice({
|
||||
name: "projects",
|
||||
initialState,
|
||||
reducers: {
|
||||
setProjectsDataSlice(state, action: PayloadAction<Project[]>) {
|
||||
state.projectsData = action.payload;
|
||||
},
|
||||
setProjectSummarySlice(state, action: PayloadAction<ProjectSummary>) {
|
||||
state.projectSummary = action.payload;
|
||||
},
|
||||
setSelectedProjectSlice(state, action: PayloadAction<Project | null>) {
|
||||
state.selectedProject = action.payload;
|
||||
},
|
||||
|
||||
initializeProjectTracking(state, action: PayloadAction<{ projectId: string; initialTime: number }>) {
|
||||
const { projectId, initialTime } = action.payload;
|
||||
|
||||
if (!state.projectTrackingMap[projectId]) {
|
||||
state.projectTrackingMap[projectId] = {
|
||||
elapsedTime: initialTime,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
isTracking: false,
|
||||
isPaused: false,
|
||||
pauseStartTime: null,
|
||||
totalPausedTime: 0,
|
||||
accumulatedTime: initialTime,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectTracking(state, action: PayloadAction<{ projectId: string; data: Partial<TrackingData> }>) {
|
||||
const { projectId, data } = action.payload;
|
||||
|
||||
if (state.projectTrackingMap[projectId]) {
|
||||
state.projectTrackingMap[projectId] = {
|
||||
...state.projectTrackingMap[projectId],
|
||||
...data,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
startTracking(state, action: PayloadAction<{ projectId: string; startTime: number }>) {
|
||||
const { projectId, startTime } = action.payload;
|
||||
const project = state.projectTrackingMap[projectId];
|
||||
|
||||
if (project) {
|
||||
project.startTime = startTime;
|
||||
project.isTracking = true;
|
||||
project.isPaused = false;
|
||||
project.pauseStartTime = null;
|
||||
project.totalPausedTime = 0;
|
||||
}
|
||||
},
|
||||
|
||||
updateElapsedTime(state, action: PayloadAction<{ projectId: string; elapsedTime: number }>) {
|
||||
const { projectId, elapsedTime } = action.payload;
|
||||
const project = state.projectTrackingMap[projectId];
|
||||
if (project) {
|
||||
project.elapsedTime = elapsedTime;
|
||||
project.accumulatedTime = elapsedTime;
|
||||
}
|
||||
},
|
||||
|
||||
stopTracking(state, action: PayloadAction<{ projectId: string }>) {
|
||||
const { projectId } = action.payload;
|
||||
const project = state.projectTrackingMap[projectId];
|
||||
if (project) {
|
||||
project.isTracking = false;
|
||||
project.startTime = null;
|
||||
}
|
||||
},
|
||||
|
||||
// In your projectsSlice.ts
|
||||
stopTrackingReducer(state, action: PayloadAction<{ projectId: string }>) {
|
||||
const p = state.projectTrackingMap[action.payload.projectId];
|
||||
if (p) {
|
||||
// Only update tracking status, time will be updated from server
|
||||
p.isTracking = false;
|
||||
p.isPaused = false;
|
||||
p.startTime = null;
|
||||
p.pauseStartTime = null;
|
||||
p.endTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
export const { setProjectsDataSlice, setProjectSummarySlice, setSelectedProjectSlice, initializeProjectTracking,
|
||||
updateProjectTracking, startTracking, updateElapsedTime, stopTracking, stopTrackingReducer } =
|
||||
projectsSlice.actions;
|
||||
|
||||
export default projectsSlice.reducer;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
// Define and export the User type so other files can use it
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Define the state shape
|
||||
interface UserState {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
user: null,
|
||||
};
|
||||
|
||||
// Create the slice
|
||||
const userSlice = createSlice({
|
||||
name: "user",
|
||||
initialState,
|
||||
reducers: {
|
||||
setUserSlice: (state, action: PayloadAction<User>) => {
|
||||
state.user = action.payload;
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem("user", JSON.stringify(action.payload));
|
||||
},
|
||||
clearUser: (state) => {
|
||||
state.user = null;
|
||||
localStorage.removeItem("user");
|
||||
},
|
||||
loadUserFromStorage: (state) => {
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
state.user = JSON.parse(storedUser) as User;
|
||||
} catch (error) {
|
||||
console.error("Invalid user in localStorage:", error);
|
||||
localStorage.removeItem("user");
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Export actions and reducer
|
||||
export const { setUserSlice, clearUser, loadUserFromStorage } = userSlice.actions;
|
||||
export default userSlice.reducer;
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// src/store/windowSlice.ts
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface OwnerInfo {
|
||||
processId: number;
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface WindowInfo {
|
||||
platform: string;
|
||||
id: number;
|
||||
title: string;
|
||||
owner: OwnerInfo;
|
||||
bounds: Record<string, number>;
|
||||
contentBounds: Record<string, number>;
|
||||
memoryUsage: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ProjectWindowLog {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
appName: string;
|
||||
windowTitle: string;
|
||||
platform: string;
|
||||
duration: number; // in seconds
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
memoryUsage: number;
|
||||
}
|
||||
|
||||
interface WindowState {
|
||||
activeWindow: WindowInfo | null;
|
||||
activeSince: number | null;
|
||||
activeProjectId: string | null;
|
||||
activeProjectName: string | null;
|
||||
windowLogs: ProjectWindowLog[];
|
||||
}
|
||||
|
||||
const initialState: WindowState = {
|
||||
activeWindow: null,
|
||||
activeSince: null,
|
||||
activeProjectId: null,
|
||||
activeProjectName: null,
|
||||
windowLogs: [],
|
||||
};
|
||||
|
||||
interface SetActiveWindowPayload {
|
||||
window: WindowInfo;
|
||||
projectId: string;
|
||||
projectName: string | null;
|
||||
}
|
||||
|
||||
const windowSlice = createSlice({
|
||||
name: "window",
|
||||
initialState,
|
||||
reducers: {
|
||||
// ✅ Start tracking window for a project
|
||||
setActiveWindow: (state, action: PayloadAction<SetActiveWindowPayload>) => {
|
||||
const { window, projectId, projectName } = action.payload;
|
||||
const now = Date.now();
|
||||
|
||||
// 🕒 Log previous window before switching
|
||||
if (state.activeWindow && state.activeSince && state.activeProjectId) {
|
||||
const duration = Math.floor((now - state.activeSince) / 1000);
|
||||
|
||||
const logEntry: ProjectWindowLog = {
|
||||
projectId: state.activeProjectId,
|
||||
projectName: state.activeProjectName || "Untitled",
|
||||
appName: state.activeWindow.owner.name,
|
||||
windowTitle: state.activeWindow.title,
|
||||
platform: state.activeWindow.platform,
|
||||
duration,
|
||||
startTime: new Date(state.activeSince).toISOString(),
|
||||
endTime: new Date(now).toISOString(),
|
||||
memoryUsage: state.activeWindow.memoryUsage,
|
||||
};
|
||||
|
||||
state.windowLogs.push(logEntry);
|
||||
console.log("🧾 Log saved:", logEntry);
|
||||
}
|
||||
|
||||
// 🔁 Update new active window + project context
|
||||
state.activeWindow = window;
|
||||
state.activeSince = now;
|
||||
state.activeProjectId = projectId;
|
||||
state.activeProjectName = projectName;
|
||||
},
|
||||
|
||||
// ✅ Stop tracking (finalize)
|
||||
finalizeActiveWindow: (state) => {
|
||||
if (state.activeWindow && state.activeSince && state.activeProjectId) {
|
||||
const now = Date.now();
|
||||
const duration = Math.floor((now - state.activeSince) / 1000);
|
||||
|
||||
const logEntry: ProjectWindowLog = {
|
||||
projectId: state.activeProjectId,
|
||||
projectName: state.activeProjectName || "Untitled",
|
||||
appName: state.activeWindow.owner.name,
|
||||
windowTitle: state.activeWindow.title,
|
||||
platform: state.activeWindow.platform,
|
||||
duration,
|
||||
startTime: new Date(state.activeSince).toISOString(),
|
||||
endTime: new Date(now).toISOString(),
|
||||
memoryUsage: state.activeWindow.memoryUsage,
|
||||
};
|
||||
|
||||
state.windowLogs.push(logEntry);
|
||||
console.log("✅ Final log saved:", logEntry);
|
||||
}
|
||||
|
||||
state.activeWindow = null;
|
||||
state.activeSince = null;
|
||||
state.activeProjectId = null;
|
||||
state.activeProjectName = null;
|
||||
},
|
||||
|
||||
clearWindowLogs: (state) => {
|
||||
state.windowLogs = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setActiveWindow, finalizeActiveWindow, clearWindowLogs } =
|
||||
windowSlice.actions;
|
||||
|
||||
export default windowSlice.reducer;
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
// utils/projectLimits.ts
|
||||
|
||||
interface Project {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
totalWorkTime: string;
|
||||
totalWorkMs: number;
|
||||
dedicatedHours: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse time string (e.g., "10h", "30m", "2h 15m", "0h 5m 30s") to total minutes
|
||||
*/
|
||||
export const parseTimeToMinutes = (timeStr: string): number => {
|
||||
if (!timeStr) return 0;
|
||||
|
||||
const hourMatch = timeStr.match(/(\d+)h/);
|
||||
const minMatch = timeStr.match(/(\d+)m/);
|
||||
|
||||
const hours = hourMatch ? parseInt(hourMatch[1]) : 0;
|
||||
const minutes = minMatch ? parseInt(minMatch[1]) : 0;
|
||||
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse time string to total seconds
|
||||
*/
|
||||
export const parseTimeToSeconds = (timeStr: string): number => {
|
||||
if (!timeStr) return 0;
|
||||
|
||||
const hourMatch = timeStr.match(/(\d+)h/);
|
||||
const minMatch = timeStr.match(/(\d+)m/);
|
||||
const secMatch = timeStr.match(/(\d+)s/);
|
||||
|
||||
const hours = hourMatch ? parseInt(hourMatch[1]) : 0;
|
||||
const minutes = minMatch ? parseInt(minMatch[1]) : 0;
|
||||
const seconds = secMatch ? parseInt(secMatch[1]) : 0;
|
||||
|
||||
return (hours * 3600) + (minutes * 60) + seconds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format minutes to readable time string
|
||||
*/
|
||||
export const formatMinutesToTime = (minutes: number): string => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to readable time string
|
||||
*/
|
||||
export const formatSecondsToTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if project has reached its daily limit
|
||||
*/
|
||||
export const isProjectLimitReached = (project: Project): boolean => {
|
||||
const totalWorkedSeconds = parseTimeToSeconds(project.totalWorkTime);
|
||||
const dedicatedSeconds = parseTimeToSeconds(project.dedicatedHours);
|
||||
|
||||
return totalWorkedSeconds >= dedicatedSeconds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get remaining time for a project in seconds
|
||||
*/
|
||||
export const getRemainingTimeInSeconds = (project: Project): number => {
|
||||
const totalWorkedSeconds = parseTimeToSeconds(project.totalWorkTime);
|
||||
const dedicatedSeconds = parseTimeToSeconds(project.dedicatedHours);
|
||||
|
||||
const remaining = dedicatedSeconds - totalWorkedSeconds;
|
||||
return Math.max(0, remaining); // Never return negative
|
||||
};
|
||||
|
||||
/**
|
||||
* Get remaining time for a project (legacy - in minutes)
|
||||
*/
|
||||
export const getRemainingTime = (project: Project): number => {
|
||||
const totalWorked = parseTimeToMinutes(project.totalWorkTime);
|
||||
const dedicatedLimit = parseTimeToMinutes(project.dedicatedHours);
|
||||
|
||||
const remaining = dedicatedLimit - totalWorked;
|
||||
return Math.max(0, remaining); // Never return negative
|
||||
};
|
||||
|
||||
/**
|
||||
* Get formatted remaining time string
|
||||
*/
|
||||
export const getFormattedRemainingTime = (project: Project): string => {
|
||||
const remainingSeconds = getRemainingTimeInSeconds(project);
|
||||
return formatSecondsToTime(remainingSeconds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if project can start tracking
|
||||
*/
|
||||
export const canStartTracking = (project: Project): {
|
||||
canStart: boolean;
|
||||
reason?: string;
|
||||
remaining?: string;
|
||||
remainingSeconds?: number;
|
||||
} => {
|
||||
if (isProjectLimitReached(project)) {
|
||||
return {
|
||||
canStart: false,
|
||||
reason: "Daily limit reached for this project",
|
||||
remaining: "0m",
|
||||
remainingSeconds: 0
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSeconds = getRemainingTimeInSeconds(project);
|
||||
|
||||
return {
|
||||
canStart: true,
|
||||
remaining: getFormattedRemainingTime(project),
|
||||
remainingSeconds
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import ReactQuill from "react-quill-new";
|
||||
import Quill from "quill";
|
||||
// import ImageResize from "quill-image-resize-module-react";
|
||||
|
||||
// Quill.register("modules/imageResize", ImageResize);
|
||||
|
||||
export const quillModules = {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
["bold", "italic", "underline"],
|
||||
["image", "link"],
|
||||
],
|
||||
// imageResize: {},
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// utils/timeDisplay.js
|
||||
export const getDisplayTime = (project:any, trackingData:any) => {
|
||||
if (!project) return "0h 0m 0s";
|
||||
|
||||
// If tracking is active, calculate current time based on server time + session time
|
||||
if (trackingData?.isTracking && trackingData.startTime) {
|
||||
const baseSeconds = parseTimeStringToSeconds(project.totalWorkTime);
|
||||
const sessionSeconds = Math.floor((Date.now() - trackingData.startTime) / 1000);
|
||||
const totalSeconds = baseSeconds + sessionSeconds;
|
||||
return formatSecondsToTimeString(totalSeconds);
|
||||
}
|
||||
|
||||
// If not tracking, just return the server's totalWorkTime
|
||||
return project.totalWorkTime;
|
||||
};
|
||||
|
||||
export const parseTimeStringToSeconds = (timeString:any) => {
|
||||
if (!timeString || typeof timeString !== 'string') return 0;
|
||||
|
||||
try {
|
||||
const hourMatch = timeString.match(/(\d+)h/);
|
||||
const minMatch = timeString.match(/(\d+)m/);
|
||||
const secMatch = timeString.match(/(\d+)s/);
|
||||
|
||||
const hours = hourMatch ? parseInt(hourMatch[1]) : 0;
|
||||
const minutes = minMatch ? parseInt(minMatch[1]) : 0;
|
||||
const seconds = secMatch ? parseInt(secMatch[1]) : 0;
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
} catch (error) {
|
||||
console.error('Error parsing time string:', timeString, error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatSecondsToTimeString = (totalSeconds:any) => {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// utils/timeParser.js
|
||||
export const parseTimeStringToMs = (timeString:string) => {
|
||||
if (!timeString) return 0;
|
||||
|
||||
const hourMatch = timeString.match(/(\d+)h/);
|
||||
const minMatch = timeString.match(/(\d+)m/);
|
||||
const secMatch = timeString.match(/(\d+)s/);
|
||||
|
||||
const hours = hourMatch ? parseInt(hourMatch[1]) : 0;
|
||||
const minutes = minMatch ? parseInt(minMatch[1]) : 0;
|
||||
const seconds = secMatch ? parseInt(secMatch[1]) : 0;
|
||||
|
||||
return (hours * 3600 + minutes * 60 + seconds) * 1000;
|
||||
};
|
||||
|
||||
export const parseTimeStringToSeconds = (timeString:string) => {
|
||||
return Math.floor(parseTimeStringToMs(timeString) / 1000);
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -1,3 +1,13 @@
|
|||
declare module "quill";
|
||||
declare module "quill-image-resize-module";
|
||||
declare module "quill-image-drop-module";
|
||||
|
||||
declare module "quill-image-resize-module-react" {
|
||||
const ImageResize: any;
|
||||
export default ImageResize;
|
||||
}
|
||||
|
||||
|
||||
type Statistics = {
|
||||
cpuUsage: number;
|
||||
ramUsage: number;
|
||||
|
|
@ -23,14 +33,246 @@ type EventPayloadMapping = {
|
|||
|
||||
type UnsubscribeFunction = () => void;
|
||||
|
||||
interface Window {
|
||||
electron: {
|
||||
login: (credentials: { email: string; password: string }) => Promise<any>;
|
||||
logout: () => Promise<any>;
|
||||
getToken: (tokenKey: "ACCESS_TOKEN" | "REFRESH_TOKEN") => Promise<string | null>;
|
||||
uploadScreenshot: () => {};
|
||||
getActiveAppDetails: () => {};
|
||||
interface ProjectListResponse {
|
||||
success: boolean;
|
||||
vaId?: string;
|
||||
vaName?: string;
|
||||
totalProjects?: number;
|
||||
activeProjects?: number;
|
||||
awayProjects?: number;
|
||||
totalWorkTime?: string;
|
||||
totalWorkMs?: number;
|
||||
data?: any[]; // you can replace `any[]` with a typed array later
|
||||
status: number
|
||||
}
|
||||
|
||||
interface TabActivity {
|
||||
title: string;
|
||||
appName: string;
|
||||
from: string;
|
||||
to: string;
|
||||
durationMs: number;
|
||||
durationFormatted: string;
|
||||
}
|
||||
|
||||
// Each session includes tabs and time span
|
||||
interface ProjectSession {
|
||||
from: string;
|
||||
to: string;
|
||||
totalSessionTime: string;
|
||||
tabs: TabActivity[];
|
||||
}
|
||||
|
||||
// Complete response for project logs
|
||||
interface ProjectLogResponse {
|
||||
vaId: string;
|
||||
vaName: string;
|
||||
vaEmail: string;
|
||||
totalWorkTime: string;
|
||||
totalWorkMs: number;
|
||||
sessions: ProjectSession[];
|
||||
}
|
||||
|
||||
|
||||
interface ProfileData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dob: string;
|
||||
age: string;
|
||||
address: string;
|
||||
bankName: string;
|
||||
accountHolderName: string;
|
||||
accountNumber: string;
|
||||
routingNumber: string;
|
||||
phone: string;
|
||||
skype: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
paymentMethod: string;
|
||||
payoneerAccount: string;
|
||||
veemAccount: string;
|
||||
wiseEmail: string
|
||||
_id: string;
|
||||
}
|
||||
|
||||
type LeaveRequestResponse = {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
interface ScheduleItem {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
status: string | null;
|
||||
ScheduleDate: string;
|
||||
cutOffTime: string | null;
|
||||
periodEnd: string | null;
|
||||
periodStart: string | null;
|
||||
}
|
||||
|
||||
|
||||
interface PayslipItem {
|
||||
totalHoursFormatted: string;
|
||||
payrollSchedule: string;
|
||||
totalPayAmount: number;
|
||||
status: string;
|
||||
date: string | null;
|
||||
createdDate: string;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface PayslipResponse {
|
||||
payrolls: PayslipItem[];
|
||||
vaPortalId: string;
|
||||
vaName: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
interface RegionResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
data?: any;
|
||||
retry?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
||||
interface TrackingLog {
|
||||
title: string;
|
||||
appName: string;
|
||||
url: string;
|
||||
from: string;
|
||||
to: string;
|
||||
totalTime: number;
|
||||
}
|
||||
|
||||
interface TrackingPayload {
|
||||
vaId: string;
|
||||
projectId: string;
|
||||
logId: string;
|
||||
logs: TrackingLog[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Window {
|
||||
electron: {
|
||||
login: (credentials: { email: string; password: string }) => Promise<any>;
|
||||
logout: () => Promise<any>;
|
||||
forgotPassword: (email: string) => Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
getToken: (tokenKey: "ACCESS_TOKEN" | "REFRESH_TOKEN") => Promise<string | null>;
|
||||
uploadScreenshot: () => {};
|
||||
getActiveAppDetails: () => {};
|
||||
toggleCloseBtn: (shouldEnable: boolean) => {},
|
||||
resizeWindow: (width: number, height: number) => {},
|
||||
getRunningApps: () => {},
|
||||
captureAppScreenShot: (appTitle: string) => Promise<string>,
|
||||
onWindowUpdate: (callback: (data: any) => void) => void;
|
||||
getProjectList: () => Promise<ProjectListResponse>;
|
||||
startProjectTracking: (data: {
|
||||
vaId: string;
|
||||
projectId: string;
|
||||
type: string;
|
||||
time: string
|
||||
}) => Promise<any>;
|
||||
stopProjectTracking: (data: {
|
||||
vaId: string;
|
||||
logId: string,
|
||||
projectId: string;
|
||||
type: string;
|
||||
time: string
|
||||
}) => Promise<any>;
|
||||
trackingTabsOpeningClosing: (data: TrackingPayload) => Promise<any>;
|
||||
getProjectLogs: (
|
||||
projectId: string,
|
||||
fromDate?: string,
|
||||
toDate?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
data: any;
|
||||
message?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
getProfile: () => Promise<{
|
||||
success: boolean;
|
||||
data?: ProfileData;
|
||||
}>;
|
||||
|
||||
|
||||
changePassword: (data: {
|
||||
oldPassword: string;
|
||||
password: string;
|
||||
cPassword: string;
|
||||
}) => Promise<{ success: boolean; message?: string; data?: any }>;
|
||||
|
||||
// In your electron API handler
|
||||
updatePayment: (data: {
|
||||
paymentMethod: string;
|
||||
wiseEmail?: string;
|
||||
veemAccount?: string;
|
||||
}) => Promise<{ success: boolean; message?: string; data?: any }>;
|
||||
|
||||
|
||||
onTokenExpired: (callback: () => void) => void;
|
||||
|
||||
|
||||
getProjectSummary: (projectId: string, date: string) => Promise<any>;
|
||||
|
||||
createTimesheet: (
|
||||
payload: {
|
||||
projectId: string;
|
||||
date: string;
|
||||
loginTime: string;
|
||||
logoutTime: string;
|
||||
comment?: string;
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
data?: any;
|
||||
message?: string;
|
||||
}>;
|
||||
|
||||
createPTOAndSickLeaveRequest: (
|
||||
payload: LeaveRequestPayload
|
||||
) => Promise<LeaveRequestResponse>;
|
||||
|
||||
getSchedules: () => Promise<any>;
|
||||
|
||||
getPayslips: (vaId: string) => Promise<{
|
||||
success: boolean;
|
||||
data?: PayslipResponse[];
|
||||
message?: string;
|
||||
}>;
|
||||
|
||||
getLeaveList: () => Promise<{
|
||||
success: boolean;
|
||||
data?: any;
|
||||
}>;
|
||||
|
||||
getAdjustmentsList: (vaId: string) => Promise<{
|
||||
success: boolean;
|
||||
data?: any;
|
||||
}>;
|
||||
|
||||
checkRegion: () => Promise<RegionResponse>;
|
||||
connectVPN: () => Promise<string>;
|
||||
disconnectVPN: () => Promise<void>;
|
||||
getBaseURL: () => Promise<string>;
|
||||
updateBaseURL: (url: string) => Promise<{
|
||||
updated: boolean;
|
||||
baseURL: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
// vite.config.ts
|
||||
import { defineConfig } from "file:///C:/Users/user/OneDrive/Desktop/electron-main/node_modules/vite/dist/node/index.js";
|
||||
import react from "file:///C:/Users/user/OneDrive/Desktop/electron-main/node_modules/@vitejs/plugin-react/dist/index.js";
|
||||
import tailwindcss from "file:///C:/Users/user/OneDrive/Desktop/electron-main/node_modules/@tailwindcss/vite/dist/index.mjs";
|
||||
var vite_config_default = defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "dist-react"
|
||||
},
|
||||
server: {
|
||||
port: 5123,
|
||||
strictPort: true
|
||||
}
|
||||
});
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJDOlxcXFxVc2Vyc1xcXFx1c2VyXFxcXE9uZURyaXZlXFxcXERlc2t0b3BcXFxcZWxlY3Ryb24tbWFpblwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcdXNlclxcXFxPbmVEcml2ZVxcXFxEZXNrdG9wXFxcXGVsZWN0cm9uLW1haW5cXFxcdml0ZS5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL0M6L1VzZXJzL3VzZXIvT25lRHJpdmUvRGVza3RvcC9lbGVjdHJvbi1tYWluL3ZpdGUuY29uZmlnLnRzXCI7aW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XHJcbmltcG9ydCByZWFjdCBmcm9tICdAdml0ZWpzL3BsdWdpbi1yZWFjdCc7XHJcbmltcG9ydCB0YWlsd2luZGNzcyBmcm9tICdAdGFpbHdpbmRjc3Mvdml0ZSdcclxuXHJcbi8vIGh0dHBzOi8vdml0ZWpzLmRldi9jb25maWcvXHJcbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XHJcbiAgcGx1Z2luczogW3JlYWN0KCksdGFpbHdpbmRjc3MoKV0sXHJcbiAgYmFzZTogJy4vJyxcclxuICBidWlsZDoge1xyXG4gICAgb3V0RGlyOiAnZGlzdC1yZWFjdCcsXHJcbiAgfSxcclxuICBzZXJ2ZXI6IHtcclxuICAgIHBvcnQ6IDUxMjMsXHJcbiAgICBzdHJpY3RQb3J0OiB0cnVlLFxyXG4gIH0sXHJcbn0pO1xyXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWtVLFNBQVMsb0JBQW9CO0FBQy9WLE9BQU8sV0FBVztBQUNsQixPQUFPLGlCQUFpQjtBQUd4QixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixTQUFTLENBQUMsTUFBTSxHQUFFLFlBQVksQ0FBQztBQUFBLEVBQy9CLE1BQU07QUFBQSxFQUNOLE9BQU87QUFBQSxJQUNMLFFBQVE7QUFBQSxFQUNWO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNO0FBQUEsSUFDTixZQUFZO0FBQUEsRUFDZDtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==
|
||||
Loading…
Reference in New Issue