Initial commit

This commit is contained in:
ChuXun
2026-01-28 23:56:33 +08:00
commit f98da73376
92 changed files with 8261 additions and 0 deletions

12
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

48
frontend/src/App.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
frontend/tsconfig.json Normal file
View 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"]
}

View 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
View 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');
});
},
},
},
},
};
});