feat(회사에 올리기 화면): 나무 우클릭 요청 · 요청 중 · 회사 층 「올리기 요청 n」 띠 · 목록 미리 보기 · 승인 · 거절 · 결과 띠 (PLAN 706-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* M02_MasterTemplete_ShareRequest.ts
|
||||||
|
* 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나).
|
||||||
|
* 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) ·
|
||||||
|
* 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 M02 열 때 위 띠(닫으면 봄)
|
||||||
|
* 회사 관리자 = 회사 층 위 띠 「올리기 요청 n」 → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] ·
|
||||||
|
* [승인](폴더 고름 = 「회사로 복사…」와 같은 길 · 원본 그대로) · [거절](사유 한 줄)
|
||||||
|
* 관리자(회사 층을 고칠 수 있는 사람)는 「회사로 복사…」가 있어 요청 메뉴를 안 둠.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createBadge } from "@ui/ui_template_badge";
|
||||||
|
import { createBanner } from "@ui/ui_template_banner";
|
||||||
|
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||||
|
import { createEmpty } from "@ui/ui_template_empty";
|
||||||
|
import { createListRow } from "@ui/ui_template_list_row";
|
||||||
|
import type { TreeMenuItem } from "@ui/ui_template_tree";
|
||||||
|
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||||
|
import { call, fetchPrecomboTree, fetchTree, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||||
|
import { accessFor, ownOwner } from "./M02_MasterTemplete_Layer";
|
||||||
|
import { askCopyFolder } from "./M02_MasterTemplete_TreePanel_CopyTo";
|
||||||
|
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||||
|
import type { Target } from "./M02_MasterTemplete_TreePanel_Pick";
|
||||||
|
|
||||||
|
type ShareKind = "item" | "precombo";
|
||||||
|
|
||||||
|
export interface ShareRow {
|
||||||
|
id: string;
|
||||||
|
종류: ShareKind;
|
||||||
|
키: string;
|
||||||
|
이름: string;
|
||||||
|
경로: string[];
|
||||||
|
주인: string;
|
||||||
|
요청자: number;
|
||||||
|
요청자이름: string;
|
||||||
|
글: string;
|
||||||
|
날짜: string;
|
||||||
|
상태: "요청" | "승인" | "거절";
|
||||||
|
처리자?: string;
|
||||||
|
사유?: string;
|
||||||
|
사본?: string;
|
||||||
|
봄?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_NAME: Record<ShareKind, string> = { item: "구조물 조합", precombo: "사전조합" };
|
||||||
|
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||||
|
const post = <T>(path: string, body: object = {}): Promise<T> =>
|
||||||
|
call<T>(path, { method: "POST", body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
export interface ShareOptions {
|
||||||
|
user: SessionUser | null;
|
||||||
|
/** 볼 회사(시스템 관리자는 고른 회사) */
|
||||||
|
company: string | null;
|
||||||
|
/** 지금 페이지 층 — 회사 층이면 관리자 띠 */
|
||||||
|
layer: Layer;
|
||||||
|
owner: string | null;
|
||||||
|
showView: (label: string, node: HTMLElement) => void;
|
||||||
|
/** 사전조합 미리 보기 — 편집 칸에 읽기만으로 */
|
||||||
|
openPrecombo: (row: ShareRow) => void;
|
||||||
|
/** 구조물 조합 미리 보기 — 요청자 개인 층으로 */
|
||||||
|
openItem: (row: ShareRow) => void;
|
||||||
|
/** 승인 뒤 — 나무 · 좌측 다시 읽기 */
|
||||||
|
changed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareHandle {
|
||||||
|
/** 편집 칸 맨 위 띠 자리(관리자 「올리기 요청 n」 · 요청자 결과) */
|
||||||
|
top: HTMLElement;
|
||||||
|
/** 구조물 조합 나무 줄 덧메뉴 — 자기 개인 층 항목만 */
|
||||||
|
itemMenu: (target: Target) => TreeMenuItem[];
|
||||||
|
/** 사전조합 나무 줄 덧메뉴 — 자기 개인 층만(`mine`) */
|
||||||
|
precomboMenu: (mine: boolean, key: string, name: string) => TreeMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShare(opt: ShareOptions): ShareHandle {
|
||||||
|
const top = el("div", { attrs: { "data-share": "" } });
|
||||||
|
const companyOwner = opt.company ?? ownOwner("company", opt.user);
|
||||||
|
// 요청할 사람 = 회사가 있고 회사 층을 못 고침(고칠 수 있으면 「회사로 복사…」)
|
||||||
|
const asker =
|
||||||
|
!!ownOwner("personal", opt.user) && !accessFor("company", companyOwner, opt.user).canWrite;
|
||||||
|
const boss =
|
||||||
|
opt.layer === "company" &&
|
||||||
|
!!companyOwner &&
|
||||||
|
accessFor("company", companyOwner, opt.user).canWrite;
|
||||||
|
const q = opt.company ? `?company=${encodeURIComponent(opt.company)}` : "";
|
||||||
|
let rows: ShareRow[] = [];
|
||||||
|
let listHost: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const pending = (kind: ShareKind, key: string): boolean =>
|
||||||
|
rows.some((r) => r.종류 === kind && r.키 === key && r.상태 === "요청");
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
if (!asker && !boss) return;
|
||||||
|
rows = (await call<{ 줄: ShareRow[] }>(`/share-requests${q}`).catch(() => ({ 줄: [] }))).줄;
|
||||||
|
paintTop();
|
||||||
|
if (listHost?.isConnected) paintList(listHost);
|
||||||
|
};
|
||||||
|
|
||||||
|
function paintTop(): void {
|
||||||
|
if (boss) {
|
||||||
|
const n = rows.filter((r) => r.상태 === "요청").length;
|
||||||
|
top.replaceChildren(
|
||||||
|
...(rows.length
|
||||||
|
? [
|
||||||
|
createBanner({
|
||||||
|
tone: n ? "warning" : "info",
|
||||||
|
text: n
|
||||||
|
? `회사에 올리기 요청 ${n} 건 — 승인하면 회사 층에 사본`
|
||||||
|
: "올리기 요청 없음",
|
||||||
|
actions: [
|
||||||
|
createButton({ label: `올리기 요청 ${n}`, size: "sm", onClick: openList }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const done = rows.filter((r) => r.봄 === false);
|
||||||
|
top.replaceChildren(
|
||||||
|
...(done.length
|
||||||
|
? [
|
||||||
|
createBanner({
|
||||||
|
tone: done.some((r) => r.상태 === "거절") ? "warning" : "success",
|
||||||
|
title: "회사에 올리기 결과",
|
||||||
|
text: done
|
||||||
|
.map((r) => `「${r.이름}」 ${r.상태}${r.사유 ? `(${r.사유})` : ""}`)
|
||||||
|
.join(" · "),
|
||||||
|
onClose: () => void post(`/share-requests/seen`).catch(() => undefined),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(kind: ShareKind, key: string, name: string): Promise<void> {
|
||||||
|
const got = await askText(
|
||||||
|
`회사에 올리기 요청 — ${name}`,
|
||||||
|
[{ key: "글", label: "요청 글(선택)", optional: true }],
|
||||||
|
"요청",
|
||||||
|
);
|
||||||
|
if (!got) return;
|
||||||
|
try {
|
||||||
|
await post("/share-requests", { 종류: kind, 키: key, 글: got.글 });
|
||||||
|
showToast("요청함 — 회사 관리자가 승인하면 회사 층에 올라감", "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`요청 못 함 — ${why(error)}`, "error");
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = (kind: ShareKind, key: string, name: string): TreeMenuItem[] =>
|
||||||
|
pending(kind, key)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "회사에 올리기 요청 중",
|
||||||
|
run: () => {},
|
||||||
|
disabledReason: "회사 관리자가 아직 안 봄",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [{ label: "회사에 올리기 요청", run: () => void ask(kind, key, name) }];
|
||||||
|
|
||||||
|
async function approve(row: ShareRow): Promise<void> {
|
||||||
|
const title = `회사에 올리기 승인 — ${row.이름}`;
|
||||||
|
const dest =
|
||||||
|
row.종류 === "item"
|
||||||
|
? await askCopyFolder(fetchTree, { layer: "company", owner: companyOwner, label: title }, 1)
|
||||||
|
: await askFolder(
|
||||||
|
title,
|
||||||
|
(await fetchPrecomboTree("company", companyOwner).catch(() => null))?.폴더 ?? [],
|
||||||
|
() => false,
|
||||||
|
[],
|
||||||
|
"넣을 폴더",
|
||||||
|
);
|
||||||
|
if (!dest) return;
|
||||||
|
try {
|
||||||
|
await post(`/share-requests/${row.id}/approve${q}`, { 경로: dest });
|
||||||
|
showToast(`「${row.이름}」 회사 층에 올림 — ${dest.join(" / ") || "맨 위"}`, "success");
|
||||||
|
opt.changed();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`승인 못 함 — ${why(error)}`, "error");
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(row: ShareRow): Promise<void> {
|
||||||
|
const got = await askText(
|
||||||
|
`거절 — ${row.이름}`,
|
||||||
|
[{ key: "사유", label: "사유(선택)", optional: true }],
|
||||||
|
"거절",
|
||||||
|
);
|
||||||
|
if (!got) return;
|
||||||
|
try {
|
||||||
|
await post(`/share-requests/${row.id}/reject${q}`, { 사유: got.사유 });
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`거절 못 함 — ${why(error)}`, "error");
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintList(host: HTMLElement): void {
|
||||||
|
const line = (r: ShareRow): HTMLElement => {
|
||||||
|
const tools =
|
||||||
|
r.상태 === "요청"
|
||||||
|
? el("span", {
|
||||||
|
className: "ui-toolbar",
|
||||||
|
children: [
|
||||||
|
createButton({
|
||||||
|
label: "미리 보기",
|
||||||
|
variant: "ghost",
|
||||||
|
size: "sm",
|
||||||
|
onClick: () => (r.종류 === "item" ? opt.openItem(r) : opt.openPrecombo(r)),
|
||||||
|
}),
|
||||||
|
createButton({ label: "승인", size: "sm", onClick: () => void approve(r) }),
|
||||||
|
createButton({
|
||||||
|
label: "거절",
|
||||||
|
variant: "danger-ghost",
|
||||||
|
size: "sm",
|
||||||
|
onClick: () => void reject(r),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
: createBadge(
|
||||||
|
`${r.상태}${r.사유 ? ` · ${r.사유}` : ""}`,
|
||||||
|
r.상태 === "승인" ? "success" : "danger",
|
||||||
|
);
|
||||||
|
const sub = [KIND_NAME[r.종류], r.요청자이름, r.날짜.slice(0, 10).replace(/-/g, "."), r.글];
|
||||||
|
return createListRow({
|
||||||
|
label: r.이름,
|
||||||
|
sub: sub.filter(Boolean).join(" · "),
|
||||||
|
trailing: tools,
|
||||||
|
size: 36,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 기다리는 것 먼저 · 그다음 최근 처리
|
||||||
|
const order = [...rows].sort(
|
||||||
|
(a, b) => Number(b.상태 === "요청") - Number(a.상태 === "요청") || (a.날짜 < b.날짜 ? 1 : -1),
|
||||||
|
);
|
||||||
|
host.replaceChildren(
|
||||||
|
...(order.length
|
||||||
|
? order.map(line)
|
||||||
|
: [createEmpty({ text: "올리기 요청 없음", small: true })]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openList(): void {
|
||||||
|
listHost = el("div", { className: "ui-sidebar-list", attrs: { "data-share-list": "" } });
|
||||||
|
paintList(listHost);
|
||||||
|
opt.showView("회사에 올리기 요청", listHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
return {
|
||||||
|
top,
|
||||||
|
itemMenu: (t) =>
|
||||||
|
asker && opt.layer === "personal" && !opt.owner && t.kind === "item"
|
||||||
|
? menu("item", t.item.key, t.item.경로[t.item.경로.length - 1] ?? t.item.key)
|
||||||
|
: [],
|
||||||
|
precomboMenu: (mine, key, name) => (asker && mine ? menu("precombo", key, name) : []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ export interface TreePanelOptions {
|
|||||||
};
|
};
|
||||||
/** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */
|
/** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */
|
||||||
copyTo?: CopyTarget[];
|
copyTo?: CopyTarget[];
|
||||||
|
more?: (target: Target) => TreeMenuItem[]; // 줄 하나 덧메뉴(706-1 회사에 올리기 요청)
|
||||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
/** 항목을 누름 — 표에서 그 열을 고름 */
|
/** 항목을 누름 — 표에서 그 열을 고름 */
|
||||||
@@ -226,6 +227,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
|||||||
return [
|
return [
|
||||||
...(opt.canEdit ? menuOf(target) : []),
|
...(opt.canEdit ? menuOf(target) : []),
|
||||||
...copyItem([target]),
|
...copyItem([target]),
|
||||||
|
...(opt.more?.(target) ?? []),
|
||||||
...(target.kind === "folder" ? pasteItem(target.path) : []),
|
...(target.kind === "folder" ? pasteItem(target.path) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
STRUCTURE_TABLE,
|
STRUCTURE_TABLE,
|
||||||
type StructureColumn,
|
type StructureColumn,
|
||||||
} from "./M02_MasterTemplete_Structure";
|
} from "./M02_MasterTemplete_Structure";
|
||||||
|
import { createShare } from "./M02_MasterTemplete_ShareRequest";
|
||||||
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
|
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
|
||||||
import {
|
import {
|
||||||
companyOwner,
|
companyOwner,
|
||||||
@@ -91,10 +92,32 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
|||||||
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
|
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
|
||||||
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
|
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
|
||||||
});
|
});
|
||||||
|
// 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠
|
||||||
|
const share = createShare({
|
||||||
|
user,
|
||||||
|
company,
|
||||||
|
layer,
|
||||||
|
owner,
|
||||||
|
showView: (label, node) =>
|
||||||
|
void main.confirmLeave().then((ok) => ok && main.showView(label, node)),
|
||||||
|
openPrecombo: (r) =>
|
||||||
|
void main.open({
|
||||||
|
layer: "personal",
|
||||||
|
owner: r.주인,
|
||||||
|
canWrite: false,
|
||||||
|
projectId: null,
|
||||||
|
kind: "precombo",
|
||||||
|
name: r.키,
|
||||||
|
label: r.이름,
|
||||||
|
}),
|
||||||
|
openItem: (r) => void go("personal", r.주인, "구조물조합"),
|
||||||
|
changed: () => void Promise.all([side.refresh(), tree.refresh()]),
|
||||||
|
});
|
||||||
const tree = createTreePanel({
|
const tree = createTreePanel({
|
||||||
layer,
|
layer,
|
||||||
owner,
|
owner,
|
||||||
copyTo,
|
copyTo,
|
||||||
|
more: share.itemMenu,
|
||||||
canEdit: canEdit(layer, isAdmin),
|
canEdit: canEdit(layer, isAdmin),
|
||||||
onPick: (item) => void main.openTable(layer, item.열),
|
onPick: (item) => void main.openTable(layer, item.열),
|
||||||
settle: () => main.settle(),
|
settle: () => main.settle(),
|
||||||
@@ -138,7 +161,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
|||||||
() => void Promise.all([side.refresh(), tree.refresh()]),
|
() => void Promise.all([side.refresh(), tree.refresh()]),
|
||||||
() => side.paint(),
|
() => side.paint(),
|
||||||
{
|
{
|
||||||
top: readOnlyBar?.root,
|
top: el("div", { children: [...(readOnlyBar ? [readOnlyBar.root] : []), share.top] }),
|
||||||
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
|
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
|
||||||
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}),
|
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}),
|
||||||
onColumn: (column) => {
|
onColumn: (column) => {
|
||||||
@@ -222,7 +245,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
|||||||
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
||||||
peopleTree("구조물조합", tree.root),
|
peopleTree("구조물조합", tree.root),
|
||||||
],
|
],
|
||||||
precombo: { scope: () => pre, bar: preBar },
|
precombo: {
|
||||||
|
scope: () => pre,
|
||||||
|
bar: preBar,
|
||||||
|
more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export interface SideOptions {
|
|||||||
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
|
||||||
tree?: HTMLElement[];
|
tree?: HTMLElement[];
|
||||||
/** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
/** 「일위대가 사전조합」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
|
||||||
precombo: Pick<PrecomboOptions, "scope" | "bar">;
|
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SideHandle {
|
export interface SideHandle {
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export interface PrecomboOptions {
|
|||||||
confirmLeave: () => Promise<boolean>;
|
confirmLeave: () => Promise<boolean>;
|
||||||
onOpen: (sel: Selection | null) => void;
|
onOpen: (sel: Selection | null) => void;
|
||||||
getOpen: () => Selection | null;
|
getOpen: () => Selection | null;
|
||||||
|
/** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */
|
||||||
|
more?: (key: string, name: string) => TreeMenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrecomboHandle {
|
export interface PrecomboHandle {
|
||||||
@@ -237,7 +239,8 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle {
|
|||||||
const label = row.제목 || row.이름;
|
const label = row.제목 || row.이름;
|
||||||
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
|
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
|
||||||
targets.set(id, { kind: "item", item });
|
targets.set(id, { kind: "item", item });
|
||||||
return { id, label, tip: row.이름, count: null, run: () => void open(row) };
|
const menu = () => opt.more?.(row.이름, label) ?? [];
|
||||||
|
return { id, label, tip: row.이름, count: null, run: () => void open(row), menu };
|
||||||
};
|
};
|
||||||
const folderNode = (node: PrecomboFolder): TreeNode => {
|
const folderNode = (node: PrecomboFolder): TreeNode => {
|
||||||
const id = folderIdOf(node.path);
|
const id = folderIdOf(node.path);
|
||||||
|
|||||||
Reference in New Issue
Block a user