인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가
This commit is contained in:
@@ -33,6 +33,7 @@ const operationNames = (route: Route) =>
|
||||
|
||||
type NavigationFixture = {
|
||||
pageExitAudit?: boolean;
|
||||
cityOfficeRequestIds?: string[];
|
||||
decoratedNpcChiefs?: boolean;
|
||||
officerLevel: number;
|
||||
permission: number;
|
||||
@@ -646,6 +647,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
|
||||
return response({
|
||||
myGeneral: { id: 7, name: '메뉴검증장수' },
|
||||
serverId: state.serverId ?? 'che_fixture_season',
|
||||
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
|
||||
profile: state.profile ?? 'che',
|
||||
gameIdx: state.gameIdx ?? 101,
|
||||
year: state.currentYear ?? 185,
|
||||
@@ -815,6 +817,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
|
||||
if (operation === 'general.getFrontStatus') {
|
||||
return response({
|
||||
serverId: state.serverId ?? 'che_fixture_season',
|
||||
cityOfficeRequests: { generalId: 1, nationId: 1, ids: state.cityOfficeRequestIds ?? [] },
|
||||
onlineUserCount: 1,
|
||||
onlineNations: '위(1)',
|
||||
onlineGenerals: '메뉴검증장수',
|
||||
@@ -7135,3 +7138,53 @@ test.describe('page exit navigation', () => {
|
||||
expect(context.pages()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
for (const width of [1000, 390]) {
|
||||
test(`city office request toast announces new IDs without repeat at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 0,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
cityOfficeRequestIds: [],
|
||||
latestVote: null,
|
||||
};
|
||||
await installRealtimeHarness(page);
|
||||
await installFixture(page, state);
|
||||
await waitForMain(page);
|
||||
await waitForMainRealtime(page);
|
||||
await expect(page.locator('.office-request-notice')).toHaveCount(0);
|
||||
state.cityOfficeRequestIds = ['30:760'];
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
const toast = page.locator('.office-request-notice');
|
||||
await expect(toast).toContainText('새 관직 요청');
|
||||
const rect = await toast.boundingBox();
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
|
||||
await page.screenshot({ path: testInfo.outputPath(`office-request-toast-${width}.png`), fullPage: true });
|
||||
await writeFile(testInfo.outputPath(`office-request-toast-${width}.json`), JSON.stringify(rect));
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
await expect(toast).toBeVisible();
|
||||
await toast.getByRole('button', { name: '관직 요청 알림 닫기' }).click();
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
await expect(toast).toHaveCount(0);
|
||||
await expect(page.locator('.office-request-link')).toContainText('관직 요청 1건');
|
||||
await page.reload();
|
||||
await waitForMainRealtime(page);
|
||||
await expect(toast).toHaveCount(0);
|
||||
state.cityOfficeRequestIds = ['30:760', '31:760'];
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ frontStatus: true }));
|
||||
await expect(toast).toContainText('대기 2건');
|
||||
await writeFile(
|
||||
testInfo.outputPath(`office-request-toast-${width}.html`),
|
||||
await toast.evaluate((element) => element.outerHTML)
|
||||
);
|
||||
await toast.getByRole('link').click();
|
||||
await expect(page).toHaveURL(/\/nation\/personnel#city-office-requests$/);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { acceptAppConfirmation } from './appConfirmation.js';
|
||||
type Role = 'leader' | 'head' | 'member';
|
||||
type FixtureState = {
|
||||
role: Role;
|
||||
officeRequests?: Record<string, unknown>;
|
||||
officeMutation?: Record<string, unknown>;
|
||||
failNextRate?: boolean;
|
||||
failPersonnelLoad?: boolean;
|
||||
testIcons?: boolean;
|
||||
@@ -238,6 +240,24 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (operation === 'auth.status') return response({ ok: true });
|
||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조조' } });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
if (operation === 'nation.getCityOfficeRequests')
|
||||
return response(
|
||||
state.officeRequests ?? {
|
||||
canManage: state.role !== 'member',
|
||||
generalId: 6,
|
||||
nationId: 1,
|
||||
serverId: 'office-season',
|
||||
city: { id: 1, name: '허창' },
|
||||
options: [],
|
||||
requests: [],
|
||||
running: true,
|
||||
tickSeconds: 600,
|
||||
}
|
||||
);
|
||||
if (operation === 'nation.cityOfficeRequest') {
|
||||
state.officeMutation = jsonInput;
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (operation === 'nation.getPersonnelInfo') {
|
||||
return state.failPersonnelLoad
|
||||
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
|
||||
@@ -1099,3 +1119,119 @@ for (const width of [500, 1000]) {
|
||||
await verifyVerticalAlignment(page, testInfo, ['.diplomacy-row > div', '.budget-row > span', '.blue-heading']);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1000, 390]) {
|
||||
test(`city office requests show defaults and both decisions at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state: FixtureState = {
|
||||
role: 'head',
|
||||
rate: 20,
|
||||
officeRequests: {
|
||||
canManage: true,
|
||||
generalId: 2,
|
||||
nationId: 1,
|
||||
serverId: 'office-season',
|
||||
city: { id: 1, name: '허창' },
|
||||
options: [],
|
||||
running: true,
|
||||
tickSeconds: 600,
|
||||
requests: ['approve', 'reject'].map((defaultDecision, index) => ({
|
||||
id: `3:${index}`,
|
||||
generalId: 3 + index,
|
||||
generalName: index ? '장료' : '허저',
|
||||
cityId: 1,
|
||||
cityName: '허창',
|
||||
officerLevel: index ? 3 : 4,
|
||||
status: 'pending',
|
||||
defaultDecision,
|
||||
defaultReason: index ? 'secret' : 'vacant',
|
||||
dueYear: 190,
|
||||
dueMonth: 2,
|
||||
remainingSeconds: 540,
|
||||
invalidReason: null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.goto('nation/personnel');
|
||||
const panel = page.getByRole('region', { name: '도시 관직 자원' });
|
||||
await expect(panel.getByText('미처리 시 자동 승인', { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText('미처리 시 자동 거부', { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole('button', { name: '승인', exact: true })).toHaveCount(2);
|
||||
await expect(panel.getByRole('button', { name: '거부', exact: true })).toHaveCount(2);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await panel.evaluate((element) => ({
|
||||
width: element.getBoundingClientRect().width,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
font: getComputedStyle(element).fontSize,
|
||||
}));
|
||||
expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth);
|
||||
await page.screenshot({ path: testInfo.outputPath(`office-requests-${width}.png`), fullPage: true });
|
||||
await writeFile(testInfo.outputPath(`office-requests-${width}.json`), JSON.stringify(geometry));
|
||||
await writeFile(
|
||||
testInfo.outputPath(`office-requests-${width}.html`),
|
||||
await panel.evaluate((element) => element.outerHTML)
|
||||
);
|
||||
const approval = panel.getByRole('button', { name: '승인', exact: true }).first();
|
||||
await approval.hover();
|
||||
await approval.focus();
|
||||
await expect(approval).toBeFocused();
|
||||
await writeFile(
|
||||
testInfo.outputPath(`office-requests-button-${width}.json`),
|
||||
JSON.stringify(
|
||||
await approval.evaluate((element) => ({
|
||||
color: getComputedStyle(element).color,
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
lineHeight: getComputedStyle(element).lineHeight,
|
||||
pointer: getComputedStyle(element).cursor,
|
||||
focused: document.activeElement === element,
|
||||
}))
|
||||
)
|
||||
);
|
||||
await panel.getByRole('button', { name: '승인', exact: true }).nth(1).click();
|
||||
await expect(page.getByTestId('game-notice-dialog')).toContainText('승인 시 기밀 열람 가능');
|
||||
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '승인', exact: true }).click();
|
||||
await expect.poll(() => state.officeMutation?.action).toBe('approve');
|
||||
expect(state.officeMutation).toMatchObject({ targetGeneralId: 4, officeRequestId: '3:1' });
|
||||
await panel.getByRole('button', { name: '거부', exact: true }).first().click();
|
||||
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '거부', exact: true }).click();
|
||||
await expect.poll(() => state.officeMutation?.action).toBe('reject');
|
||||
});
|
||||
test(`city office volunteer confirms the default at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state: FixtureState = {
|
||||
role: 'member',
|
||||
rate: 20,
|
||||
officeRequests: {
|
||||
canManage: false,
|
||||
generalId: 6,
|
||||
nationId: 1,
|
||||
serverId: 'office-season',
|
||||
city: { id: 1, name: '허창' },
|
||||
requests: [],
|
||||
running: true,
|
||||
tickSeconds: 600,
|
||||
options: [
|
||||
{ level: 4, allowed: true, reason: null, defaultDecision: 'approve', defaultReason: 'vacant' },
|
||||
{
|
||||
level: 3,
|
||||
allowed: false,
|
||||
reason: '지력 부족',
|
||||
defaultDecision: 'approve',
|
||||
defaultReason: 'vacant',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.goto('nation/personnel');
|
||||
const panel = page.getByRole('region', { name: '도시 관직 자원' });
|
||||
await expect(panel.getByRole('button', { name: '자원', exact: true }).nth(1)).toBeDisabled();
|
||||
await panel.getByRole('button', { name: '자원', exact: true }).first().click();
|
||||
await expect(page.getByTestId('game-notice-dialog')).toContainText('미처리 시 자동 승인');
|
||||
await page.getByTestId('game-notice-dialog').getByRole('button', { name: '자원', exact: true }).click();
|
||||
await expect.poll(() => state.officeMutation?.action).toBe('request');
|
||||
expect(state.officeMutation).toEqual({ action: 'request', officerLevel: 4 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
const props = defineProps<{
|
||||
serverId?: string;
|
||||
summary?: { generalId: number; nationId: number; ids: string[] };
|
||||
}>();
|
||||
const visible = ref(false);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let identity = '';
|
||||
let sessionSeen = new Set<string>();
|
||||
const count = computed(() => props.summary?.ids.length ?? 0);
|
||||
const dismiss = () => {
|
||||
visible.value = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
watch(
|
||||
() => [props.serverId, props.summary] as const,
|
||||
() => {
|
||||
const summary = props.summary;
|
||||
if (!summary || !props.serverId) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
const key = `state.${props.serverId}.${summary.generalId}.${summary.nationId}.officeRequests`;
|
||||
if (identity !== key) {
|
||||
dismiss();
|
||||
identity = key;
|
||||
sessionSeen = new Set();
|
||||
}
|
||||
if (!summary.ids.length) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
let seen: string[] = [];
|
||||
try {
|
||||
const stored: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
|
||||
if (Array.isArray(stored)) seen = stored.filter((id): id is string => typeof id === 'string');
|
||||
} catch {
|
||||
/* 저장소를 사용할 수 없어도 요청 목록은 표시한다. */
|
||||
}
|
||||
const fresh = summary.ids.some((id) => !seen.includes(id) && !sessionSeen.has(id));
|
||||
if (!fresh) return;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify([...new Set([...seen, ...summary.ids])].slice(-500)));
|
||||
} catch {
|
||||
/* private browsing */
|
||||
}
|
||||
summary.ids.forEach((id) => sessionSeen.add(id));
|
||||
visible.value = true;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(dismiss, 60_000);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
onUnmounted(() => clearTimeout(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="count" class="office-request-link" to="/nation/personnel#city-office-requests"
|
||||
>인사부 · 관직 요청 {{ count }}건</RouterLink
|
||||
>
|
||||
<aside v-if="visible && count" class="office-request-notice" role="status" aria-live="polite">
|
||||
<div>
|
||||
<strong>새 관직 요청</strong
|
||||
><button type="button" aria-label="관직 요청 알림 닫기" @click="dismiss">×</button>
|
||||
</div>
|
||||
<RouterLink to="/nation/personnel#city-office-requests" @click="dismiss"
|
||||
>대기 {{ count }}건 · 인사부에서 확인</RouterLink
|
||||
>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.office-request-link {
|
||||
display: block;
|
||||
padding: 5px 8px;
|
||||
background: #30302c;
|
||||
color: #ffe7a3;
|
||||
text-align: center;
|
||||
}
|
||||
.office-request-notice {
|
||||
position: fixed;
|
||||
z-index: 1050;
|
||||
bottom: 100px;
|
||||
left: 12px;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 12px;
|
||||
background: #282823;
|
||||
color: #fff;
|
||||
border: 1px solid #d4bd77;
|
||||
box-shadow: 0 3px 12px #0008;
|
||||
}
|
||||
.office-request-notice > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.office-request-notice button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
}
|
||||
.office-request-notice a {
|
||||
color: #ffe7a3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
import { useGameFeedback } from '../../composables/useGameFeedback';
|
||||
|
||||
type Response = Awaited<ReturnType<typeof trpc.nation.getCityOfficeRequests.query>>;
|
||||
type Request = Response['requests'][number];
|
||||
const emit = defineEmits<{ changed: [] }>();
|
||||
const data = ref<Response | null>(null);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const feedback = useGameFeedback();
|
||||
const labels = { 2: '종사', 3: '군사', 4: '태수' };
|
||||
const defaults = { approve: '미처리 시 자동 승인', reject: '미처리 시 자동 거부' };
|
||||
const reasons = { vacant: '공석', npc: 'M장·N장 재직', absent: '재직자 부재', secret: '기밀 열람 승인 필요' };
|
||||
const states = { approved: '임명 완료', rejected: '거부', withdrawn: '철회', cancelled: '취소', pending: '대기' };
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let loading = false;
|
||||
let disposed = false;
|
||||
const load = async () => {
|
||||
if (loading || disposed) return;
|
||||
loading = true;
|
||||
try {
|
||||
const result = await trpc.nation.getCityOfficeRequests.query();
|
||||
if (!disposed) {
|
||||
data.value = result;
|
||||
error.value = '';
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) error.value = '관직 요청을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
const remaining = (request: Request) => {
|
||||
if (request.remainingSeconds === null) return '시계 준비 중';
|
||||
if (!data.value?.running) return '시간 정지';
|
||||
if (request.remainingSeconds <= 0) return '월 처리 대기';
|
||||
return `${Math.ceil(request.remainingSeconds / 60)}분 남음`;
|
||||
};
|
||||
const mutate = async (input: Parameters<typeof trpc.nation.cityOfficeRequest.mutate>[0]) => {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await trpc.nation.cityOfficeRequest.mutate(input);
|
||||
await load();
|
||||
emit('changed');
|
||||
feedback.success(
|
||||
input.action === 'request'
|
||||
? '자원했습니다.'
|
||||
: input.action === 'approve'
|
||||
? '임명했습니다.'
|
||||
: input.action === 'reject'
|
||||
? '거부했습니다.'
|
||||
: '철회했습니다.'
|
||||
);
|
||||
} catch (err) {
|
||||
feedback.error(err instanceof Error ? err.message : '요청 처리에 실패했습니다.');
|
||||
await load();
|
||||
emit('changed');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
const request = async (option: Response['options'][number]) => {
|
||||
if (
|
||||
!data.value ||
|
||||
!(await feedback.confirm({
|
||||
title: `${data.value.city?.name ?? ''} ${labels[option.level]} 자원`,
|
||||
message: `${defaults[option.defaultDecision]}\n분기 1회 · 게임 한 달 이상 대기`,
|
||||
acknowledgeLabel: '자원',
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await mutate({ action: 'request', officerLevel: option.level });
|
||||
};
|
||||
const decide = async (entry: Request, action: 'approve' | 'reject' | 'withdraw') => {
|
||||
const verb = action === 'approve' ? '승인' : action === 'reject' ? '거부' : '철회';
|
||||
const message = `${entry.generalName} · ${entry.cityName} ${labels[entry.officerLevel]}${action === 'approve' && entry.defaultReason === 'secret' ? '\n승인 시 기밀 열람 가능' : ''}`;
|
||||
if (!(await feedback.confirm({ title: `관직 요청 ${verb}`, message, acknowledgeLabel: verb }))) return;
|
||||
await mutate({ action, targetGeneralId: entry.generalId, officeRequestId: entry.id });
|
||||
};
|
||||
const refreshVisible = () => {
|
||||
if (!document.hidden) void load();
|
||||
};
|
||||
onMounted(() => {
|
||||
void load();
|
||||
timer = setInterval(refreshVisible, 15_000);
|
||||
window.addEventListener('focus', refreshVisible);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
disposed = true;
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('focus', refreshVisible);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="city-office-requests" class="office-requests" aria-label="도시 관직 자원">
|
||||
<div class="request-heading">
|
||||
<strong>도시 관직 자원</strong><span>분기 1회</span
|
||||
><button type="button" class="legacy-button" :disabled="busy" @click="load">새로고침</button>
|
||||
</div>
|
||||
<div v-if="error" role="alert">{{ error }}</div>
|
||||
<template v-if="data">
|
||||
<div v-if="!data.canManage" class="request-options">
|
||||
<strong>{{ data.city?.name ?? '아국 도시 체류 필요' }}</strong>
|
||||
<div v-for="option in data.options" :key="option.level" class="request-option">
|
||||
<strong>{{ labels[option.level] }}</strong>
|
||||
<div>
|
||||
<span :class="['default-decision', option.defaultDecision]">{{
|
||||
defaults[option.defaultDecision]
|
||||
}}</span
|
||||
><small>{{ option.reason ?? reasons[option.defaultReason] }}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="legacy-button"
|
||||
:disabled="busy || !option.allowed"
|
||||
@click="request(option)"
|
||||
>
|
||||
자원
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="request-list-heading">
|
||||
{{
|
||||
data.canManage
|
||||
? `대기 요청 ${data.requests.filter((entry) => entry.status === 'pending').length}건`
|
||||
: '내 요청'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="!data.requests.length" class="empty">
|
||||
{{ data.canManage ? '대기 중인 요청 없음' : '요청 없음' }}
|
||||
</div>
|
||||
<article v-for="entry in data.requests" :key="entry.id" class="request-row">
|
||||
<div class="request-title">
|
||||
<strong>{{ entry.generalName }}</strong
|
||||
><span>{{ entry.cityName }} {{ labels[entry.officerLevel] }}</span>
|
||||
</div>
|
||||
<template v-if="entry.status === 'pending'">
|
||||
<strong :class="['default-decision', entry.defaultDecision]">{{
|
||||
entry.invalidReason ? '조건 변경 · 취소 예정' : defaults[entry.defaultDecision]
|
||||
}}</strong>
|
||||
<small>{{ reasons[entry.defaultReason] }}</small>
|
||||
<span>{{ entry.dueYear }}년 {{ entry.dueMonth }}월 · {{ remaining(entry) }}</span>
|
||||
<small v-if="entry.invalidReason" class="invalid">{{ entry.invalidReason }} · 처리 시 취소</small>
|
||||
<div class="request-actions">
|
||||
<template v-if="data.canManage"
|
||||
><button
|
||||
type="button"
|
||||
class="legacy-button"
|
||||
:disabled="busy || !!entry.invalidReason"
|
||||
@click="decide(entry, 'approve')"
|
||||
>
|
||||
승인</button
|
||||
><button
|
||||
type="button"
|
||||
class="legacy-button"
|
||||
:disabled="busy"
|
||||
@click="decide(entry, 'reject')"
|
||||
>
|
||||
거부
|
||||
</button></template
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="legacy-button"
|
||||
:disabled="busy"
|
||||
@click="decide(entry, 'withdraw')"
|
||||
>
|
||||
철회
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else
|
||||
>{{ states[entry.status] }}<small v-if="entry.resultReason">{{ entry.resultReason }}</small></span
|
||||
>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.office-requests {
|
||||
margin: 8px 0;
|
||||
border: 1px solid #777;
|
||||
background: #242424;
|
||||
padding: 8px;
|
||||
}
|
||||
.request-heading,
|
||||
.request-title,
|
||||
.request-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.request-heading {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.request-heading > span {
|
||||
color: #ccc;
|
||||
}
|
||||
.request-heading > button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.request-option {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 7px 0;
|
||||
border-top: 1px solid #555;
|
||||
}
|
||||
.request-option small,
|
||||
.request-row small {
|
||||
display: block;
|
||||
color: #ccc;
|
||||
}
|
||||
.default-decision.approve {
|
||||
color: #9ae2a3;
|
||||
}
|
||||
.default-decision.reject,
|
||||
.request-row .invalid {
|
||||
color: #ffd28b;
|
||||
}
|
||||
.request-list-heading {
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.request-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(180px, 1.3fr) minmax(150px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 5px 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #555;
|
||||
}
|
||||
.request-title {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.request-row > .default-decision {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
.request-row > small {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
.request-row > span {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.request-row > .invalid {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
}
|
||||
.request-actions {
|
||||
grid-column: 4;
|
||||
grid-row: 1 / span 2;
|
||||
justify-content: end;
|
||||
}
|
||||
.request-actions button {
|
||||
min-width: 56px;
|
||||
}
|
||||
.empty {
|
||||
padding: 8px 0;
|
||||
color: #ccc;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.request-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.request-row > * {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
.request-actions {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import CityOfficeRequestNotice from '../components/personnel/CityOfficeRequestNotice.vue';
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
@@ -335,6 +336,7 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CityOfficeRequestNotice :server-id="frontStatus?.serverId" :summary="frontStatus?.cityOfficeRequests" />
|
||||
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
||||
<div class="survey-notice-title">
|
||||
<strong>설문조사 안내</strong>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||
|
||||
import CityOfficeRequests from '../components/personnel/CityOfficeRequests.vue';
|
||||
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
|
||||
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
@@ -322,6 +323,8 @@ onMounted(() => void loadPersonnel());
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<CityOfficeRequests @changed="loadPersonnel" />
|
||||
|
||||
<div v-if="error" class="feedback error" role="alert">{{ error }}</div>
|
||||
<div v-if="loading" class="loading">불러오는 중...</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user