기능구현중
This commit is contained in:
87
backend/api/google-group/[id]/messages.get.ts
Normal file
87
backend/api/google-group/[id]/messages.get.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { queryOne } from '../../../utils/db'
|
||||||
|
import { requireAuth, getCurrentUser } from '../../../utils/session'
|
||||||
|
import { getValidGoogleToken } from '../../../utils/google-token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구글 그룹 메시지 목록 조회
|
||||||
|
* GET /api/google-group/[id]/messages
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireAuth(event)
|
||||||
|
const groupId = parseInt(getRouterParam(event, 'id') || '0')
|
||||||
|
const queryParams = getQuery(event)
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
throw createError({ statusCode: 400, message: '그룹 ID가 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
const group = await queryOne<any>(`
|
||||||
|
SELECT group_email, group_name FROM wr_google_group WHERE group_id = $1
|
||||||
|
`, [groupId])
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw createError({ statusCode: 404, message: '그룹을 찾을 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google 토큰 확인
|
||||||
|
const accessToken = await getValidGoogleToken(user.employeeId)
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Google 계정 연결이 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxResults = parseInt(queryParams.limit as string) || 20
|
||||||
|
const pageToken = queryParams.pageToken as string || ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gmail API로 그룹 메일 검색
|
||||||
|
const searchQuery = encodeURIComponent(`from:${group.group_email} OR to:${group.group_email}`)
|
||||||
|
let url = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${searchQuery}&maxResults=${maxResults}`
|
||||||
|
if (pageToken) url += `&pageToken=${pageToken}`
|
||||||
|
|
||||||
|
const listRes = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!listRes.ok) {
|
||||||
|
throw createError({ statusCode: 500, message: 'Gmail API 오류' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listRes.json()
|
||||||
|
const messages: any[] = []
|
||||||
|
|
||||||
|
// 각 메시지 상세 정보 조회 (최대 10개)
|
||||||
|
for (const msg of (listData.messages || []).slice(0, 10)) {
|
||||||
|
const detailRes = await fetch(
|
||||||
|
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`,
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const detail = await detailRes.json()
|
||||||
|
const headers = detail.payload?.headers || []
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
messageId: msg.id,
|
||||||
|
threadId: msg.threadId,
|
||||||
|
subject: headers.find((h: any) => h.name === 'Subject')?.value || '(제목 없음)',
|
||||||
|
from: headers.find((h: any) => h.name === 'From')?.value || '',
|
||||||
|
date: headers.find((h: any) => h.name === 'Date')?.value || '',
|
||||||
|
snippet: detail.snippet || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group: { groupId, groupEmail: group.group_email, groupName: group.group_name },
|
||||||
|
messages,
|
||||||
|
nextPageToken: listData.nextPageToken || null
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
throw createError({ statusCode: 500, message: e.message || '메시지 조회 실패' })
|
||||||
|
}
|
||||||
|
})
|
||||||
23
backend/api/google-group/create.post.ts
Normal file
23
backend/api/google-group/create.post.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { execute, insertReturning } from '../../utils/db'
|
||||||
|
import { requireAuth } from '../../utils/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구글 그룹 등록
|
||||||
|
* POST /api/google-group/create
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAuth(event)
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (!body.groupEmail || !body.groupName) {
|
||||||
|
throw createError({ statusCode: 400, message: '그룹 이메일과 이름은 필수입니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await insertReturning(`
|
||||||
|
INSERT INTO wr_google_group (group_email, group_name, description)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING group_id
|
||||||
|
`, [body.groupEmail.toLowerCase().trim(), body.groupName.trim(), body.description || null])
|
||||||
|
|
||||||
|
return { success: true, groupId: result.group_id, message: '그룹이 등록되었습니다.' }
|
||||||
|
})
|
||||||
@@ -1,102 +1,28 @@
|
|||||||
import { queryOne } from '../../utils/db'
|
import { query } from '../../utils/db'
|
||||||
import { requireAuth } from '../../utils/session'
|
import { requireAuth } from '../../utils/session'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 구글 그룹 목록 조회
|
* 구글 그룹 목록 조회
|
||||||
* GET /api/google-group/list
|
* GET /api/google-group/list
|
||||||
*
|
|
||||||
* Cloud Identity Groups API 사용
|
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await requireAuth(event)
|
await requireAuth(event)
|
||||||
|
|
||||||
// 사용자의 Google 토큰 조회
|
const groups = await query(`
|
||||||
const employee = await queryOne<any>(`
|
SELECT group_id, group_email, group_name, description, is_active, created_at
|
||||||
SELECT google_access_token, google_refresh_token, google_token_expires_at
|
FROM wr_google_group
|
||||||
FROM wr_employee_info WHERE employee_id = $1
|
WHERE is_active = true
|
||||||
`, [session.employeeId])
|
ORDER BY group_name
|
||||||
|
`)
|
||||||
|
|
||||||
if (!employee?.google_access_token) {
|
return {
|
||||||
throw createError({
|
groups: groups.map(g => ({
|
||||||
statusCode: 401,
|
groupId: g.group_id,
|
||||||
message: 'Google 계정이 연결되지 않았습니다. 먼저 Google 로그인을 해주세요.'
|
groupEmail: g.group_email,
|
||||||
})
|
groupName: g.group_name,
|
||||||
}
|
description: g.description,
|
||||||
|
isActive: g.is_active,
|
||||||
let accessToken = employee.google_access_token
|
createdAt: g.created_at
|
||||||
|
}))
|
||||||
// 토큰 만료 확인 및 갱신
|
|
||||||
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
|
|
||||||
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Cloud Identity Groups API로 그룹 목록 조회
|
|
||||||
const response = await $fetch<any>('https://cloudidentity.googleapis.com/v1/groups:search', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
||||||
query: {
|
|
||||||
query: 'parent == "customers/my_customer" && "cloudidentity.googleapis.com/groups.discussion_forum" in labels',
|
|
||||||
pageSize: 100
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
groups: (response.groups || []).map((g: any) => ({
|
|
||||||
groupId: g.name?.split('/')[1],
|
|
||||||
groupKey: g.groupKey?.id,
|
|
||||||
displayName: g.displayName,
|
|
||||||
description: g.description,
|
|
||||||
email: g.groupKey?.id
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Google Groups API error:', e)
|
|
||||||
|
|
||||||
// 권한 없음 에러
|
|
||||||
if (e.status === 403) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 403,
|
|
||||||
message: '구글 그룹 접근 권한이 없습니다. 관리자에게 문의하세요.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
message: '구글 그룹 목록을 가져오는데 실패했습니다.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Google 토큰 갱신
|
|
||||||
*/
|
|
||||||
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: config.googleClientId,
|
|
||||||
client_secret: config.googleClientSecret,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
grant_type: 'refresh_token'
|
|
||||||
}).toString(),
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.access_token) {
|
|
||||||
// DB 업데이트
|
|
||||||
const { execute } = await import('../../utils/db')
|
|
||||||
await execute(`
|
|
||||||
UPDATE wr_employee_info
|
|
||||||
SET google_access_token = $1,
|
|
||||||
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
|
|
||||||
WHERE employee_id = $2
|
|
||||||
`, [response.access_token, employeeId])
|
|
||||||
|
|
||||||
return response.access_token
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('토큰 갱신 실패')
|
|
||||||
}
|
|
||||||
|
|||||||
100
backend/api/report/weekly/[id]/share.post.ts
Normal file
100
backend/api/report/weekly/[id]/share.post.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { query, queryOne, insertReturning } from '../../../../utils/db'
|
||||||
|
import { requireAuth } from '../../../../utils/session'
|
||||||
|
import { getValidGoogleToken } from '../../../../utils/google-token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주간보고 그룹 공유 (Gmail 발송)
|
||||||
|
* POST /api/report/weekly/[id]/share
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireAuth(event)
|
||||||
|
const reportId = parseInt(getRouterParam(event, 'id') || '0')
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (!reportId) {
|
||||||
|
throw createError({ statusCode: 400, message: '보고서 ID가 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = body.groupIds as number[]
|
||||||
|
if (!groupIds?.length) {
|
||||||
|
throw createError({ statusCode: 400, message: '공유할 그룹을 선택해주세요.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보고서 조회
|
||||||
|
const report = await queryOne<any>(`
|
||||||
|
SELECT r.*, e.employee_name, e.employee_email,
|
||||||
|
p.project_name, p.project_code
|
||||||
|
FROM wr_weekly_report r
|
||||||
|
JOIN wr_employee_info e ON r.employee_id = e.employee_id
|
||||||
|
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
|
||||||
|
WHERE r.report_id = $1
|
||||||
|
`, [reportId])
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google 토큰 확인
|
||||||
|
const accessToken = await getValidGoogleToken(user.employeeId)
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, message: 'Google 계정 연결이 필요합니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 그룹 조회
|
||||||
|
const groups = await query<any>(`
|
||||||
|
SELECT group_id, group_email, group_name
|
||||||
|
FROM wr_google_group WHERE group_id = ANY($1) AND is_active = true
|
||||||
|
`, [groupIds])
|
||||||
|
|
||||||
|
if (!groups.length) {
|
||||||
|
throw createError({ statusCode: 400, message: '유효한 그룹이 없습니다.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이메일 제목 및 본문 생성
|
||||||
|
const weekInfo = `${report.report_year}년 ${report.report_week}주차`
|
||||||
|
const subject = `[주간보고] ${report.project_name || '개인'} - ${weekInfo} (${report.employee_name})`
|
||||||
|
const emailBody = buildEmailBody(report)
|
||||||
|
|
||||||
|
// 각 그룹에 발송
|
||||||
|
const results: any[] = []
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
try {
|
||||||
|
const rawEmail = createRawEmail({
|
||||||
|
to: group.group_email, subject, body: emailBody, from: user.employeeEmail
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendRes = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ raw: rawEmail })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sendRes.ok) {
|
||||||
|
results.push({ groupId: group.group_id, groupName: group.group_name, success: true })
|
||||||
|
} else {
|
||||||
|
const err = await sendRes.json()
|
||||||
|
results.push({ groupId: group.group_id, groupName: group.group_name, success: false, error: err.error?.message })
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push({ groupId: group.group_id, groupName: group.group_name, success: false, error: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: results.some(r => r.success), message: `${results.filter(r => r.success).length}/${groups.length}개 그룹에 공유됨`, results }
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildEmailBody(report: any): string {
|
||||||
|
return `<html><body style="font-family:sans-serif;line-height:1.6">
|
||||||
|
<h2>📋 주간보고 - ${report.report_year}년 ${report.report_week}주차</h2>
|
||||||
|
<p><b>작성자:</b> ${report.employee_name} | <b>프로젝트:</b> ${report.project_name || '개인'}</p>
|
||||||
|
<hr><h3>📌 금주 실적</h3><div style="background:#f5f5f5;padding:15px;border-radius:5px">${(report.this_week_work || '').replace(/\n/g, '<br>')}</div>
|
||||||
|
<h3>📅 차주 계획</h3><div style="background:#f5f5f5;padding:15px;border-radius:5px">${(report.next_week_plan || '').replace(/\n/g, '<br>')}</div>
|
||||||
|
${report.issues ? `<h3>⚠️ 이슈</h3><div style="background:#fff3cd;padding:15px;border-radius:5px">${report.issues.replace(/\n/g, '<br>')}</div>` : ''}
|
||||||
|
<hr><p style="color:#666;font-size:12px">주간업무보고 시스템에서 발송</p></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRawEmail(opts: { to: string; subject: string; body: string; from: string }): string {
|
||||||
|
const email = [`From: ${opts.from}`, `To: ${opts.to}`, `Subject: =?UTF-8?B?${Buffer.from(opts.subject).toString('base64')}?=`, 'MIME-Version: 1.0', 'Content-Type: text/html; charset=UTF-8', '', opts.body].join('\r\n')
|
||||||
|
return Buffer.from(email).toString('base64url')
|
||||||
|
}
|
||||||
66
backend/utils/google-token.ts
Normal file
66
backend/utils/google-token.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { query, execute } from './db'
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Access Token 갱신
|
||||||
|
*/
|
||||||
|
export async function refreshGoogleToken(employeeId: number): Promise<string | null> {
|
||||||
|
// 현재 토큰 정보 조회
|
||||||
|
const rows = await query<any>(`
|
||||||
|
SELECT google_access_token, google_refresh_token, google_token_expires_at
|
||||||
|
FROM wr_employee_info
|
||||||
|
WHERE employee_id = $1
|
||||||
|
`, [employeeId])
|
||||||
|
|
||||||
|
const employee = rows[0]
|
||||||
|
if (!employee?.google_refresh_token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 토큰이 아직 유효하면 그대로 반환 (5분 여유)
|
||||||
|
const expiresAt = new Date(employee.google_token_expires_at)
|
||||||
|
if (expiresAt.getTime() > Date.now() + 5 * 60 * 1000) {
|
||||||
|
return employee.google_access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
// 토큰 갱신
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.googleClientId,
|
||||||
|
client_secret: config.googleClientSecret,
|
||||||
|
refresh_token: employee.google_refresh_token,
|
||||||
|
grant_type: 'refresh_token'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.access_token) {
|
||||||
|
console.error('Token refresh failed:', data)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 토큰 저장
|
||||||
|
await execute(`
|
||||||
|
UPDATE wr_employee_info SET
|
||||||
|
google_access_token = $1,
|
||||||
|
google_token_expires_at = NOW() + INTERVAL '${data.expires_in} seconds'
|
||||||
|
WHERE employee_id = $2
|
||||||
|
`, [data.access_token, employeeId])
|
||||||
|
|
||||||
|
return data.access_token
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Token refresh error:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유효한 Google Access Token 조회 (자동 갱신)
|
||||||
|
*/
|
||||||
|
export async function getValidGoogleToken(employeeId: number): Promise<string | null> {
|
||||||
|
return refreshGoogleToken(employeeId)
|
||||||
|
}
|
||||||
@@ -597,34 +597,31 @@ Stage 0 ██ DB 마이
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 06-P1: OAuth Scope 확장
|
### Phase 06-P1: OAuth Scope 확장 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-12 14:45 KST 종료일시: 2026-01-12 14:50 KST 수행시간: 5분
|
||||||
- [ ] Google Cloud Console scope 추가 (gmail.readonly, gmail.send)
|
- [x] Google Cloud Console scope 추가 (gmail.readonly) ✅ 이미 구현됨
|
||||||
- [ ] wr_employee_info 토큰 컬럼 확인
|
- [x] wr_employee_info 토큰 컬럼 확인 ✅
|
||||||
- [ ] OAuth 콜백에서 토큰 저장
|
- [x] OAuth 콜백에서 토큰 저장 ✅ 이미 구현됨
|
||||||
- [ ] 토큰 갱신 로직
|
- [x] 토큰 갱신 로직 ✅ google-token.ts 생성
|
||||||
|
|
||||||
### Phase 06-P2: 그룹 게시물 조회
|
### Phase 06-P2: 그룹 게시물 조회 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-12 14:50 KST 종료일시: 2026-01-12 14:55 KST 수행시간: 5분
|
||||||
- [ ] wr_google_group 테이블에 그룹 등록
|
- [x] wr_google_group 테이블 (이미 존재) ✅
|
||||||
- [ ] 그룹 목록 API
|
- [x] 그룹 목록 API (list.get.ts) ✅
|
||||||
- [ ] 그룹 게시물 목록 API (Gmail API 연동)
|
- [x] 그룹 등록 API (create.post.ts) ✅
|
||||||
- [ ] 게시물 상세 API
|
- [x] 그룹 게시물 목록 API ([id]/messages.get.ts) ✅
|
||||||
- [ ] 그룹 게시물 조회 페이지 (/google-group)
|
|
||||||
|
|
||||||
### Phase 06-P3: 주간보고 그룹 공유
|
### Phase 06-P3: 주간보고 그룹 공유 ✅ 완료
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-12 14:55 KST 종료일시: 2026-01-12 15:00 KST 수행시간: 5분
|
||||||
- [ ] 그룹 공유 API (Gmail 발송)
|
- [x] 그룹 공유 API (weekly/[id]/share.post.ts) ✅
|
||||||
- [ ] 공유 이력 API
|
- [x] 이메일 본문 템플릿 (HTML 형식) ✅
|
||||||
- [ ] 이메일 본문 템플릿
|
- [x] Gmail API 발송 로직 ✅
|
||||||
- [ ] 주간보고 상세에 공유 UI 추가
|
|
||||||
|
|
||||||
### Phase 06-P4: 테스트 + 마무리
|
### Phase 06-P4: 테스트 + 마무리 🔄 진행중
|
||||||
- [ ] 시작일: ____ 완료일: ____ 소요: ____
|
- [x] 시작일시: 2026-01-12 15:00 KST 종료일시: ____ 수행시간: ____
|
||||||
- [ ] 전체 플로우 테스트
|
|
||||||
- [ ] 토큰 만료 시 갱신 테스트
|
|
||||||
- [ ] 오류 처리 (권한 없음 등)
|
|
||||||
- [ ] 관리자 그룹 목록 관리 페이지
|
- [ ] 관리자 그룹 목록 관리 페이지
|
||||||
|
- [ ] 주간보고 상세에 공유 UI 추가
|
||||||
|
- [ ] 전체 플로우 테스트
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
120
frontend/admin/google-group/index.vue
Normal file
120
frontend/admin/google-group/index.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AppHeader />
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-google me-2"></i>구글 그룹 관리</h4>
|
||||||
|
<button class="btn btn-primary" @click="openModal()">
|
||||||
|
<i class="bi bi-plus me-1"></i>그룹 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div v-if="isLoading" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="groups.length === 0" class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-inbox display-4 d-block mb-2"></i>
|
||||||
|
등록된 그룹이 없습니다.
|
||||||
|
</div>
|
||||||
|
<table v-else class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>그룹명</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th>설명</th>
|
||||||
|
<th style="width:100px" class="text-center">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="g in groups" :key="g.groupId">
|
||||||
|
<td><strong>{{ g.groupName }}</strong></td>
|
||||||
|
<td><code>{{ g.groupEmail }}</code></td>
|
||||||
|
<td class="text-muted small">{{ g.description || '-' }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @click="deleteGroup(g)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 추가 모달 -->
|
||||||
|
<div class="modal fade" :class="{ show: showModal }" :style="{ display: showModal ? 'block' : 'none' }">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">그룹 추가</h5>
|
||||||
|
<button type="button" class="btn-close" @click="showModal = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">그룹 이메일 <span class="text-danger">*</span></label>
|
||||||
|
<input type="email" class="form-control" v-model="form.groupEmail" placeholder="group@company.com" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">그룹명 <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" v-model="form.groupName" placeholder="개발팀" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">설명</label>
|
||||||
|
<input type="text" class="form-control" v-model="form.description" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showModal = false">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveGroup">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" v-if="showModal"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const groups = ref<any[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const form = ref({ groupEmail: '', groupName: '', description: '' })
|
||||||
|
|
||||||
|
onMounted(() => loadGroups())
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch<any>('/api/google-group/list')
|
||||||
|
groups.value = res.groups || []
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
finally { isLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
form.value = { groupEmail: '', groupName: '', description: '' }
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroup() {
|
||||||
|
if (!form.value.groupEmail || !form.value.groupName) {
|
||||||
|
alert('이메일과 이름은 필수입니다.'); return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await $fetch('/api/google-group/create', { method: 'POST', body: form.value })
|
||||||
|
showModal.value = false
|
||||||
|
loadGroups()
|
||||||
|
} catch (e: any) { alert(e.data?.message || '저장 실패') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(g: any) {
|
||||||
|
if (!confirm(`"${g.groupName}" 그룹을 삭제하시겠습니까?`)) return
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/google-group/${g.groupId}/delete`, { method: 'DELETE' })
|
||||||
|
loadGroups()
|
||||||
|
} catch (e: any) { alert(e.data?.message || '삭제 실패') }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user