Initial commit
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Smart Office</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1768
frontend/package-lock.json
generated
Normal file
1768
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "smart-office",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/login-bg.jpg
Normal file
BIN
frontend/public/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
48
frontend/src/App.tsx
Normal file
48
frontend/src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import RequireAuth from './components/RequireAuth';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Attendance from './pages/Attendance';
|
||||
import Leave from './pages/Leave';
|
||||
import Approvals from './pages/Approvals';
|
||||
import Users from './pages/Users';
|
||||
import Notifications from './pages/Notifications';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="/attendance" element={<Attendance />} />
|
||||
<Route path="/leave" element={<Leave />} />
|
||||
<Route
|
||||
path="/approvals"
|
||||
element={
|
||||
<RequireAuth roles={['ADMIN', 'MANAGER']}>
|
||||
<Approvals />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequireAuth roles={['ADMIN', 'MANAGER']}>
|
||||
<Users />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/Layout.tsx
Normal file
58
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { Role } from '../types';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: '概览', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
{ to: '/attendance', label: '考勤', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
{ to: '/leave', label: '请假', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
{ to: '/approvals', label: '审批', roles: ['ADMIN', 'MANAGER'] as Role[] },
|
||||
{ to: '/users', label: '用户管理', roles: ['ADMIN', 'MANAGER'] as Role[] },
|
||||
{ to: '/notifications', label: '通知', roles: ['ADMIN', 'MANAGER', 'EMPLOYEE'] as Role[] },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const role = user?.role;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" />
|
||||
<div>
|
||||
<div className="brand-title">Smart Office</div>
|
||||
<div className="brand-sub">Enterprise Suite</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{NAV.filter((n) => role && n.roles.includes(role)).map((n) => (
|
||||
<NavLink key={n.to} to={n.to} className="nav-link">
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<div className="topbar-title">智能办公管理系统</div>
|
||||
<div className="topbar-user">
|
||||
<span className="badge">{user?.role}</span>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{user?.fullName}</div>
|
||||
<div className="user-sub">@{user?.username}</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={logout}>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/RequireAuth.tsx
Normal file
20
frontend/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Role } from '../types';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function RequireAuth({
|
||||
roles,
|
||||
children,
|
||||
}: {
|
||||
roles?: Role[];
|
||||
children: React.ReactElement;
|
||||
}) {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) return <div className="page">加载中...</div>;
|
||||
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
if (roles && !roles.includes(user.role)) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
49
frontend/src/lib/api.ts
Normal file
49
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ApiResponse } from '../types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? '/api/v1';
|
||||
const TOKEN_KEY = 'smartoffice_token';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}) {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
if (options.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const json = (await res.json()) as ApiResponse<T>;
|
||||
if (!res.ok || json.code !== 200) {
|
||||
throw new Error(json.message || `HTTP ${res.status}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
|
||||
};
|
||||
|
||||
export const storage = {
|
||||
token: () => localStorage.getItem(TOKEN_KEY),
|
||||
setToken: (token: string) => localStorage.setItem(TOKEN_KEY, token),
|
||||
clearToken: () => localStorage.removeItem(TOKEN_KEY),
|
||||
};
|
||||
71
frontend/src/lib/auth.tsx
Normal file
71
frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { api, storage } from './api';
|
||||
import { Role, UserDto } from '../types';
|
||||
|
||||
const USER_KEY = 'smartoffice_user';
|
||||
|
||||
type AuthState = {
|
||||
user: UserDto | null;
|
||||
role: Role | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserDto | null>(() => {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as UserDto) : null;
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = storage.token();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<UserDto>('/auth/me')
|
||||
.then((me) => {
|
||||
setUser(me);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(me));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [user]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const data = await api.post<{ token: string; user: UserDto }>('/auth/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
storage.setToken(data.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
setUser(data.user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
storage.clearToken();
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({ user, role: user?.role ?? null, loading, login, logout }),
|
||||
[user, loading]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('AuthProvider missing');
|
||||
return ctx;
|
||||
}
|
||||
16
frontend/src/main.tsx
Normal file
16
frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from './lib/auth';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
74
frontend/src/pages/Approvals.tsx
Normal file
74
frontend/src/pages/Approvals.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { LeaveDto } from '../types';
|
||||
|
||||
export default function Approvals() {
|
||||
const [list, setList] = useState<LeaveDto[]>([]);
|
||||
const [notes, setNotes] = useState<Record<number, string>>({});
|
||||
|
||||
const load = () => api.get<LeaveDto[]>('/leave-requests/pending').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const act = async (id: number, action: 'approve' | 'reject') => {
|
||||
await api.post(`/leave-requests/${id}/${action}`, {
|
||||
note: notes[id] || '',
|
||||
});
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>审批</h1>
|
||||
<p>待审批的请假请求</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>申请人</th>
|
||||
<th>类型</th>
|
||||
<th>时间</th>
|
||||
<th>原因</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.requester.fullName}</td>
|
||||
<td>{l.type}</td>
|
||||
<td>
|
||||
{new Date(l.startTime).toLocaleString()} → {new Date(l.endTime).toLocaleString()}
|
||||
</td>
|
||||
<td>{l.reason}</td>
|
||||
<td>
|
||||
<input
|
||||
className="inline-input"
|
||||
value={notes[l.id] || ''}
|
||||
onChange={(e) => setNotes({ ...notes, [l.id]: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="row">
|
||||
<button className="btn btn-primary" onClick={() => act(l.id, 'approve')}>
|
||||
通过
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => act(l.id, 'reject')}>
|
||||
拒绝
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!list.length && <div className="muted">暂无待审批记录</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/Attendance.tsx
Normal file
90
frontend/src/pages/Attendance.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { AttendanceDto } from '../types';
|
||||
|
||||
export default function Attendance() {
|
||||
const [records, setRecords] = useState<AttendanceDto[]>([]);
|
||||
const [location, setLocation] = useState('HQ');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [action, setAction] = useState('');
|
||||
|
||||
const load = () =>
|
||||
api.get<AttendanceDto[]>('/attendance/me').then((data) => {
|
||||
setRecords(data);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const checkIn = async () => {
|
||||
setAction('checkin');
|
||||
await api.post('/attendance/check-in', { location });
|
||||
await load();
|
||||
setAction('');
|
||||
};
|
||||
|
||||
const checkOut = async () => {
|
||||
setAction('checkout');
|
||||
await api.post('/attendance/check-out');
|
||||
await load();
|
||||
setAction('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>考勤</h1>
|
||||
<p>打卡与个人记录</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">打卡</div>
|
||||
<div className="row">
|
||||
<input value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
<button className="btn btn-primary" onClick={checkIn} disabled={action === 'checkin'}>
|
||||
上班打卡
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={checkOut} disabled={action === 'checkout'}>
|
||||
下班签退
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">我的考勤记录</div>
|
||||
{loading ? (
|
||||
<div className="muted">加载中...</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>签到</th>
|
||||
<th>签退</th>
|
||||
<th>工时</th>
|
||||
<th>地点</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.date}</td>
|
||||
<td>{r.checkInTime || '-'}</td>
|
||||
<td>{r.checkOutTime || '-'}</td>
|
||||
<td>{r.workHours}</td>
|
||||
<td>{r.location || '-'}</td>
|
||||
<td>
|
||||
<span className="badge">{r.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/Dashboard.tsx
Normal file
93
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { AttendanceDto, LeaveDto, NotificationDto } from '../types';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [attendance, setAttendance] = useState<AttendanceDto[]>([]);
|
||||
const [leaves, setLeaves] = useState<LeaveDto[]>([]);
|
||||
const [notifications, setNotifications] = useState<NotificationDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get<AttendanceDto[]>('/attendance/me'),
|
||||
api.get<LeaveDto[]>('/leave-requests/my'),
|
||||
api.get<NotificationDto[]>('/notifications'),
|
||||
])
|
||||
.then(([a, l, n]) => {
|
||||
setAttendance(a);
|
||||
setLeaves(l);
|
||||
setNotifications(n);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>概览</h1>
|
||||
<p>关键指标一览与近期动态</p>
|
||||
</div>
|
||||
|
||||
<div className="grid stats">
|
||||
<div className="card">
|
||||
<div className="card-title">本月考勤记录</div>
|
||||
<div className="stat">{attendance.length}</div>
|
||||
<div className="stat-sub">来自个人记录</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">我的请假申请</div>
|
||||
<div className="stat">{leaves.length}</div>
|
||||
<div className="stat-sub">当前与历史</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">通知</div>
|
||||
<div className="stat">{notifications.length}</div>
|
||||
<div className="stat-sub">含未读与已读</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<div className="card-title">最近考勤</div>
|
||||
{loading ? (
|
||||
<div className="muted">加载中...</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>签到</th>
|
||||
<th>签退</th>
|
||||
<th>工时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attendance.slice(0, 5).map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td>{a.date}</td>
|
||||
<td>{a.checkInTime || '-'}</td>
|
||||
<td>{a.checkOutTime || '-'}</td>
|
||||
<td>{a.workHours}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">最近通知</div>
|
||||
<div className="list-stagger">
|
||||
{notifications.slice(0, 5).map((n) => (
|
||||
<div key={n.id} className={`list-item ${n.readAt ? 'read' : ''}`}>
|
||||
<div className="list-title">{n.title}</div>
|
||||
<div className="list-sub">{n.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/pages/Leave.tsx
Normal file
100
frontend/src/pages/Leave.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { LeaveDto, LeaveType } from '../types';
|
||||
|
||||
const TYPES: LeaveType[] = ['ANNUAL', 'SICK', 'PERSONAL', 'MARRIAGE', 'MATERNITY', 'BEREAVEMENT'];
|
||||
|
||||
export default function Leave() {
|
||||
const [list, setList] = useState<LeaveDto[]>([]);
|
||||
const [type, setType] = useState<LeaveType>('ANNUAL');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const load = () => api.get<LeaveDto[]>('/leave-requests/my').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post('/leave-requests', {
|
||||
type,
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
endTime: new Date(endTime).toISOString(),
|
||||
reason,
|
||||
});
|
||||
setReason('');
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>请假</h1>
|
||||
<p>提交申请与查看记录</p>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div className="card">
|
||||
<div className="card-title">提交请假</div>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<label>
|
||||
类型
|
||||
<select value={type} onChange={(e) => setType(e.target.value as LeaveType)}>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
开始时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
结束时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
原因
|
||||
<textarea value={reason} onChange={(e) => setReason(e.target.value)} required />
|
||||
</label>
|
||||
<button className="btn btn-primary">提交</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">我的请假</div>
|
||||
<div className="list-stagger">
|
||||
{list.map((l) => (
|
||||
<div key={l.id} className="list-item">
|
||||
<div className="list-title">
|
||||
{l.type} · {l.status}
|
||||
</div>
|
||||
<div className="list-sub">
|
||||
{new Date(l.startTime).toLocaleString()} → {new Date(l.endTime).toLocaleString()}
|
||||
</div>
|
||||
<div className="list-sub">{l.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend/src/pages/Login.tsx
Normal file
71
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Login() {
|
||||
const { login, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('admin123');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate('/');
|
||||
}, [user, navigate]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError((err as Error).message || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card animate-in">
|
||||
<div className="login-brand">
|
||||
<div className="brand-mark" />
|
||||
<div>
|
||||
<div className="brand-title">Smart Office</div>
|
||||
<div className="brand-sub">Sign in to continue</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="form" onSubmit={submit}>
|
||||
<label>
|
||||
用户名
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <div className="alert">{error}</div>}
|
||||
|
||||
<button className="btn btn-primary" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-hint">
|
||||
<div className="chip">admin / admin123</div>
|
||||
<div className="chip">manager / manager123</div>
|
||||
<div className="chip">employee / employee123</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/pages/Notifications.tsx
Normal file
45
frontend/src/pages/Notifications.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { NotificationDto } from '../types';
|
||||
|
||||
export default function Notifications() {
|
||||
const [list, setList] = useState<NotificationDto[]>([]);
|
||||
|
||||
const load = () => api.get<NotificationDto[]>('/notifications').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const markRead = async (id: number) => {
|
||||
await api.post(`/notifications/${id}/read`);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>通知</h1>
|
||||
<p>系统与业务提醒</p>
|
||||
</div>
|
||||
|
||||
<div className="grid cols-2">
|
||||
{list.map((n) => (
|
||||
<div key={n.id} className={`card ${n.readAt ? 'read' : ''}`}>
|
||||
<div className="card-title">{n.title}</div>
|
||||
<div className="muted">{n.type}</div>
|
||||
<div className="card-content">{n.content}</div>
|
||||
<div className="card-footer">
|
||||
<span className="muted">{new Date(n.createdAt).toLocaleString()}</span>
|
||||
{!n.readAt && (
|
||||
<button className="btn btn-outline" onClick={() => markRead(n.id)}>
|
||||
标记已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/pages/Users.tsx
Normal file
146
frontend/src/pages/Users.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { Role, UserDto, UserStatus } from '../types';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const ROLES: Role[] = ['ADMIN', 'MANAGER', 'EMPLOYEE'];
|
||||
const STATUSES: UserStatus[] = ['ACTIVE', 'DISABLED', 'LOCKED'];
|
||||
|
||||
export default function Users() {
|
||||
const { role } = useAuth();
|
||||
const [list, setList] = useState<UserDto[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 'EMPLOYEE' as Role,
|
||||
});
|
||||
|
||||
const load = () => api.get<UserDto[]>('/users').then(setList);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const create = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post('/users', form);
|
||||
setForm({ username: '', password: '', fullName: '', email: '', phone: '', role: 'EMPLOYEE' });
|
||||
load();
|
||||
};
|
||||
|
||||
const updateStatus = async (id: number, status: UserStatus) => {
|
||||
await api.patch(`/users/${id}/status`, { status });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page animate-in">
|
||||
<div className="page-header">
|
||||
<h1>用户管理</h1>
|
||||
<p>组织内用户与状态</p>
|
||||
</div>
|
||||
|
||||
{role === 'ADMIN' && (
|
||||
<div className="card">
|
||||
<div className="card-title">创建用户</div>
|
||||
<form className="form grid cols-2" onSubmit={create}>
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
姓名
|
||||
<input
|
||||
value={form.fullName}
|
||||
onChange={(e) => setForm({ ...form, fullName: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
角色
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as Role })}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
邮箱
|
||||
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
电话
|
||||
<input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
</label>
|
||||
<div>
|
||||
<button className="btn btn-primary">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">用户列表</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>邮箱</th>
|
||||
<th>电话</th>
|
||||
<th>最近登录</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.fullName}</td>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.role}</td>
|
||||
<td>
|
||||
{role === 'ADMIN' ? (
|
||||
<select
|
||||
value={u.status}
|
||||
onChange={(e) => updateStatus(u.id, e.target.value as UserStatus)}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="badge">{u.status}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.email || '-'}</td>
|
||||
<td>{u.phone || '-'}</td>
|
||||
<td>{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
frontend/src/styles.css
Normal file
486
frontend/src/styles.css
Normal file
@@ -0,0 +1,486 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&family=Source+Serif+4:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f2efe9;
|
||||
--surface: #ffffff;
|
||||
--ink: #161616;
|
||||
--ink-2: #3f3f3f;
|
||||
--muted: #7a7a7a;
|
||||
--border: rgba(12, 14, 20, 0.12);
|
||||
--accent: #0f6b5f;
|
||||
--accent-2: #c7a677;
|
||||
--danger: #b9483a;
|
||||
--shadow: 0 20px 40px -30px rgba(0, 0, 0, 0.45);
|
||||
--radius: 18px;
|
||||
--font-sans: 'Sora', system-ui, sans-serif;
|
||||
--font-serif: 'Source Serif 4', serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1000px 800px at 10% 0%, rgba(15, 107, 95, 0.08), transparent 60%),
|
||||
radial-gradient(900px 700px at 100% 10%, rgba(199, 166, 119, 0.08), transparent 55%),
|
||||
linear-gradient(180deg, #f7f4ee 0%, #efebe3 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-serif);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 28px 24px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), #1a8a7a);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.nav-link:hover {
|
||||
background: rgba(15, 107, 95, 0.08);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card.read {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin: 12px 0;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-sans);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.alert {
|
||||
background: #ffe9e5;
|
||||
border: 1px solid #f3c1b8;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
color: #7a2f25;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(11, 28, 25, 0.45) 0%, rgba(11, 28, 25, 0.2) 100%),
|
||||
url('/login-bg.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.login-page::before,
|
||||
.login-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
border-radius: 50%;
|
||||
filter: blur(10px);
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(15, 107, 95, 0.4), transparent 60%);
|
||||
top: -180px;
|
||||
left: -140px;
|
||||
}
|
||||
|
||||
.login-page::after {
|
||||
background: radial-gradient(circle at 70% 30%, rgba(199, 166, 119, 0.4), transparent 60%);
|
||||
bottom: -200px;
|
||||
right: -160px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(420px, 92vw);
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 30px 70px -40px rgba(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(15, 107, 95, 0.18);
|
||||
backdrop-filter: blur(8px);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-brand .brand-mark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(140deg, #0f6b5f 0%, #1f8f7f 60%, #c7a677 140%);
|
||||
position: relative;
|
||||
box-shadow: 0 18px 40px -28px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.login-brand .brand-mark::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 9px;
|
||||
border-radius: 12px;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.8), transparent 55%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.login-brand .brand-mark::after {
|
||||
content: 'S';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.login-card .brand-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.login-card .brand-sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f1f1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.list-stagger .list-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
animation: floatIn 0.35s ease both;
|
||||
}
|
||||
|
||||
.list-item.read {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeUp 0.45s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cols-2,
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
59
frontend/src/types.ts
Normal file
59
frontend/src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type Role = 'ADMIN' | 'MANAGER' | 'EMPLOYEE';
|
||||
export type UserStatus = 'ACTIVE' | 'DISABLED' | 'LOCKED';
|
||||
export type LeaveType =
|
||||
| 'ANNUAL'
|
||||
| 'SICK'
|
||||
| 'PERSONAL'
|
||||
| 'MARRIAGE'
|
||||
| 'MATERNITY'
|
||||
| 'BEREAVEMENT';
|
||||
|
||||
export interface UserDto {
|
||||
id: number;
|
||||
username: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: Role;
|
||||
status: UserStatus;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface AttendanceDto {
|
||||
id: number;
|
||||
date: string;
|
||||
checkInTime?: string;
|
||||
checkOutTime?: string;
|
||||
workHours: number;
|
||||
status: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface LeaveDto {
|
||||
id: number;
|
||||
requester: UserDto;
|
||||
type: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
approver?: UserDto;
|
||||
decidedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationDto {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const proxyTarget = env.VITE_PROXY_TARGET || 'http://localhost:8080';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/v1': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.removeHeader('origin');
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user