인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가

This commit is contained in:
2026-09-29 09:41:01 +00:00
parent cd6ded4970
commit 5bafdfc1e7
36 changed files with 2238 additions and 20 deletions
@@ -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>
+2
View File
@@ -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>