Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1
This commit is contained in:
@@ -68,6 +68,7 @@ export interface CompanyInfo {
|
||||
business_address?: string | null;
|
||||
business_owner?: string | null;
|
||||
business_status?: string | null;
|
||||
logo_asset_id?: number | null;
|
||||
user_count?: number;
|
||||
project_count?: number;
|
||||
}
|
||||
@@ -232,7 +233,9 @@ export async function searchCompanies(query: string): Promise<CompanyInfo[]> {
|
||||
return data.companies;
|
||||
}
|
||||
|
||||
export function createCompany(payload: CreateCompanyRequest): Promise<unknown> {
|
||||
export function createCompany(
|
||||
payload: CreateCompanyRequest,
|
||||
): Promise<{ company_id: number; status: string }> {
|
||||
return request("/dashboard/user/company/create", { method: "POST", body: body(payload) });
|
||||
}
|
||||
|
||||
@@ -277,6 +280,17 @@ export async function createCompanyAsset(form: FormData): Promise<number> {
|
||||
return data.asset_id;
|
||||
}
|
||||
|
||||
/** 자산의 이름·주인을 고친다. 서명을 사람 계정에 물릴 때 쓴다 (2026-09-02). */
|
||||
export function updateCompanyAsset(
|
||||
assetId: number,
|
||||
payload: { label: string; user_id: number | null },
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/company/assets/${assetId}`, {
|
||||
method: "PUT",
|
||||
body: body(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
||||
return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -284,8 +298,26 @@ export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
||||
export const companyAssetFileUrl = (assetId: number): string =>
|
||||
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
|
||||
|
||||
export function addCompanyMember(email: string): Promise<unknown> {
|
||||
return request("/dashboard/admin/members", { method: "POST", body: body({ email }) });
|
||||
/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */
|
||||
export function addCompanyMember(
|
||||
email: string,
|
||||
profile?: { name?: string; position?: string | null; department?: string | null },
|
||||
): Promise<{ member: Member }> {
|
||||
return request("/dashboard/admin/members", {
|
||||
method: "POST",
|
||||
body: body({ email, ...profile }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */
|
||||
export function setCompanyLogo(
|
||||
logoAssetId: number | null,
|
||||
companyId?: number | null,
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/company/logo${companyQuery(companyId)}`, {
|
||||
method: "PUT",
|
||||
body: body({ logo_asset_id: logoAssetId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeCompanyMember(userId: number): Promise<unknown> {
|
||||
|
||||
@@ -289,7 +289,7 @@ async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_address,
|
||||
c.business_owner, c.business_status,
|
||||
c.business_owner, c.business_status, c.logo_asset_id,
|
||||
COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
@@ -409,58 +409,6 @@ async def join_company(user_id: int, company_id: int) -> dict[str, Any]:
|
||||
raise
|
||||
|
||||
|
||||
async def list_company_members(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, email, name, position, department, role, is_master, status
|
||||
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
||||
(company_id,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
row["role"] = _role(row.get("role"))
|
||||
row["is_master"] = bool(row.get("is_master"))
|
||||
return rows
|
||||
|
||||
|
||||
async def add_company_member(company_id: int, email: str) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id FROM users
|
||||
WHERE email = %s AND deleted_at IS NULL FOR UPDATE""",
|
||||
(email.lower(),),
|
||||
)
|
||||
user = await cursor.fetchone()
|
||||
if not user or user["company_id"] == company_id:
|
||||
await connection.rollback()
|
||||
return None
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s""",
|
||||
(company_id, user["id"]),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(user["id"])
|
||||
|
||||
|
||||
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
||||
(user_id, company_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]:
|
||||
where = "WHERE jr.company_id = %s" if company_id else ""
|
||||
params = (company_id,) if company_id else ()
|
||||
@@ -520,7 +468,7 @@ async def list_all_companies() -> list[dict[str, Any]]:
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
|
||||
c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
||||
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""회사 구성원과 회사 대표 로고 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한).
|
||||
|
||||
구성원은 도면 표제란의 사람 자리(과업책임자·분야별책임자·설계자)를 채우는 원천이라
|
||||
자산·로고와 같은 결로 묶어 둔다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
|
||||
from common_util.common_util_auth import hash_password
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
from .B01_Dashboard_Repository import _role, get_dashboard_me
|
||||
|
||||
|
||||
async def list_company_members(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, email, name, position, department, role, is_master, status
|
||||
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
||||
(company_id,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
row["role"] = _role(row.get("role"))
|
||||
row["is_master"] = bool(row.get("is_master"))
|
||||
return rows
|
||||
|
||||
|
||||
async def add_company_member(
|
||||
company_id: int,
|
||||
email: str,
|
||||
profile: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""회사에 사람을 붙인다.
|
||||
|
||||
`profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다**
|
||||
(2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는
|
||||
비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id FROM users
|
||||
WHERE email = %s AND deleted_at IS NULL FOR UPDATE""",
|
||||
(email.lower(),),
|
||||
)
|
||||
user = await cursor.fetchone()
|
||||
if not user and profile and profile.get("name"):
|
||||
await cursor.execute(
|
||||
"""INSERT INTO users (email, password_hash, name, position, department,
|
||||
company_id, role, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'USER', 'PENDING')""",
|
||||
(
|
||||
email.lower(),
|
||||
hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호
|
||||
profile["name"],
|
||||
profile.get("position"),
|
||||
profile.get("department"),
|
||||
company_id,
|
||||
),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(cursor.lastrowid)
|
||||
if not user or user["company_id"] == company_id:
|
||||
await connection.rollback()
|
||||
return None
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s""",
|
||||
(company_id, user["id"]),
|
||||
)
|
||||
await connection.commit()
|
||||
return await get_dashboard_me(user["id"])
|
||||
|
||||
|
||||
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
||||
is_master = FALSE
|
||||
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
||||
(user_id, company_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def set_company_logo(company_id: int, asset_id: int | None) -> bool:
|
||||
"""회사 대표 로고를 지정한다 (2026-09-02 사용자 확정 — 회사 등록 단계에서 받고 변경).
|
||||
|
||||
`asset_id` 가 같은 회사의 `kind='LOGO'` 자산인지는 라우터가 확인한다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE companies SET logo_asset_id = %s WHERE id = %s AND deleted_at IS NULL",
|
||||
(asset_id, company_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
@@ -12,7 +12,6 @@ from common_util.common_util_storage import read_stored_asset
|
||||
from config.config_system import PROJECT_DELETE_HARD_ENABLED
|
||||
|
||||
from .B01_Dashboard_Repository import (
|
||||
add_company_member,
|
||||
assign_user_company,
|
||||
change_user_role,
|
||||
create_company,
|
||||
@@ -26,12 +25,10 @@ from .B01_Dashboard_Repository import (
|
||||
list_all_projects,
|
||||
list_all_users,
|
||||
list_audit_logs,
|
||||
list_company_members,
|
||||
list_company_projects,
|
||||
list_join_requests,
|
||||
list_user_projects,
|
||||
process_join_request,
|
||||
remove_company_member,
|
||||
search_companies,
|
||||
soft_delete_project,
|
||||
update_admin_user,
|
||||
@@ -49,6 +46,12 @@ from .B01_Dashboard_Repository_Assets import (
|
||||
update_company_asset,
|
||||
write_company_asset_file,
|
||||
)
|
||||
from .B01_Dashboard_Repository_Members import (
|
||||
add_company_member,
|
||||
list_company_members,
|
||||
remove_company_member,
|
||||
set_company_logo,
|
||||
)
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AdminUpdateUserRequest,
|
||||
@@ -58,6 +61,7 @@ from .B01_Dashboard_Schema import (
|
||||
JoinCompanyRequest,
|
||||
ProcessJoinRequest,
|
||||
UpdateCompanyAssetRequest,
|
||||
UpdateCompanyLogoRequest,
|
||||
UpdateProjectRequest,
|
||||
UpdateUserRequest,
|
||||
)
|
||||
@@ -210,7 +214,15 @@ async def admin_add_member(
|
||||
payload: AddMemberRequest,
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
member = await add_company_member(_require_company_id(session), payload.email)
|
||||
member = await add_company_member(
|
||||
_require_company_id(session),
|
||||
payload.email,
|
||||
{
|
||||
"name": payload.name,
|
||||
"position": payload.position,
|
||||
"department": payload.department,
|
||||
},
|
||||
)
|
||||
if not member:
|
||||
raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.")
|
||||
return {"status": "success", "member": member}
|
||||
@@ -475,6 +487,26 @@ async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.put("/company/logo")
|
||||
async def company_set_logo(
|
||||
payload: UpdateCompanyLogoRequest,
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
"""회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정).
|
||||
|
||||
프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다.
|
||||
"""
|
||||
scoped = _scope_company(session, company_id)
|
||||
if payload.logo_asset_id is not None:
|
||||
asset = await get_company_asset(payload.logo_asset_id)
|
||||
if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO":
|
||||
raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.")
|
||||
if not await set_company_logo(scoped, payload.logo_asset_id):
|
||||
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/company/assets/{asset_id}/file")
|
||||
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
|
||||
|
||||
@@ -25,6 +25,16 @@ class JoinCompanyRequest(BaseModel):
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
# 계정이 아직 없는 사람도 담당자로 넣는다 (2026-09-02 사용자 확정) — 이름이 있으면
|
||||
# 그 자리에서 계정을 만든다. 비어 있으면 기존 사용자를 회사에 붙이는 옛 동작이다.
|
||||
name: str | None = Field(default=None, max_length=100)
|
||||
position: str | None = Field(default=None, max_length=100)
|
||||
department: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class UpdateCompanyLogoRequest(BaseModel):
|
||||
# 회사 대표 로고 (company_assets.id, kind=LOGO). None 이면 지정을 지운다.
|
||||
logo_asset_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class ProcessJoinRequest(BaseModel):
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface AssetFieldHandle {
|
||||
value: () => number | null;
|
||||
}
|
||||
|
||||
export interface AssetFieldOptions {
|
||||
/** 이 자산의 주인을 사람으로 못박는다 — 사용자 서명 칸(2026-09-02 사용자 확정). */
|
||||
owner?: { id: number; name: string } | null;
|
||||
/** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */
|
||||
onChange?: (assetId: number | null) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
|
||||
|
||||
/** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */
|
||||
@@ -30,8 +37,14 @@ export function createAssetField(
|
||||
initialId: number | null | undefined,
|
||||
companyId: number | null | undefined,
|
||||
user: DashboardUser,
|
||||
options: AssetFieldOptions = {},
|
||||
): AssetFieldHandle {
|
||||
let list = assets.filter((asset) => asset.kind === kind);
|
||||
const owner = options.owner ?? null;
|
||||
// 주인이 못박힌 칸은 그 사람 것(또는 아직 주인 없는 것)만 보인다.
|
||||
let list = assets.filter(
|
||||
(asset) =>
|
||||
asset.kind === kind && (!owner || asset.user_id === owner.id || asset.user_id == null),
|
||||
);
|
||||
let selected = list.find((asset) => asset.id === initialId) ?? null;
|
||||
|
||||
const root = document.createElement("div");
|
||||
@@ -58,17 +71,124 @@ export function createAssetField(
|
||||
label: "선택…",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => {
|
||||
list = fresh;
|
||||
selected = next;
|
||||
render();
|
||||
}),
|
||||
openAssetPickerModal(
|
||||
kind,
|
||||
list,
|
||||
selected?.id ?? null,
|
||||
companyId,
|
||||
user,
|
||||
(next, fresh) => {
|
||||
list = fresh;
|
||||
selected = next;
|
||||
render();
|
||||
void options.onChange?.(next?.id ?? null);
|
||||
},
|
||||
owner,
|
||||
),
|
||||
});
|
||||
row.append(preview, name, pick);
|
||||
root.append(caption, row);
|
||||
return { root, value: () => selected?.id ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다.
|
||||
* 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다.
|
||||
*/
|
||||
function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise<File | null> } {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-field";
|
||||
const caption = document.createElement("label");
|
||||
caption.className = "ui-field__label";
|
||||
caption.textContent = "또는 마우스로 그리기";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 480;
|
||||
canvas.height = 160;
|
||||
canvas.className = "b01-dashboard__sign-pad";
|
||||
const context = canvas.getContext("2d");
|
||||
let drawn = false;
|
||||
let drawing = false;
|
||||
|
||||
if (context) {
|
||||
context.lineWidth = 2.5;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.strokeStyle = "#111111";
|
||||
}
|
||||
// 캔버스 좌표는 CSS 크기가 아니라 픽셀 크기 기준이라 비율로 환산한다.
|
||||
const at = (event: PointerEvent): [number, number] => {
|
||||
const box = canvas.getBoundingClientRect();
|
||||
return [
|
||||
((event.clientX - box.left) * canvas.width) / box.width,
|
||||
((event.clientY - box.top) * canvas.height) / box.height,
|
||||
];
|
||||
};
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!context) return;
|
||||
drawing = true;
|
||||
drawn = true;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const [x, y] = at(event);
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!drawing || !context) return;
|
||||
const [x, y] = at(event);
|
||||
context.lineTo(x, y);
|
||||
context.stroke();
|
||||
});
|
||||
const stop = (): void => {
|
||||
drawing = false;
|
||||
};
|
||||
canvas.addEventListener("pointerup", stop);
|
||||
canvas.addEventListener("pointercancel", stop);
|
||||
|
||||
const clear = createButton({
|
||||
label: "지우기",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
context?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawn = false;
|
||||
},
|
||||
});
|
||||
root.append(caption, canvas, clear);
|
||||
|
||||
return {
|
||||
root,
|
||||
toFile: () =>
|
||||
new Promise((resolve) => {
|
||||
if (!drawn) return resolve(null);
|
||||
canvas.toBlob(
|
||||
(blob) => resolve(blob ? new File([blob], "signature.png", { type: "image/png" }) : null),
|
||||
"image/png",
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 고르기 모달을 버튼 하나로 연다 — 표 안(회사 목록)처럼 칸을 둘 자리가 없을 때.
|
||||
* 자산 목록은 열 때 받는다(회사가 여럿이면 미리 다 받을 이유가 없다).
|
||||
*/
|
||||
export async function openAssetPicker(
|
||||
kind: CompanyAsset["kind"],
|
||||
companyId: number,
|
||||
user: DashboardUser,
|
||||
currentId: number | null,
|
||||
onPick: (assetId: number | null) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const assets = await fetchCompanyAssets(companyId);
|
||||
openAssetPickerModal(
|
||||
kind,
|
||||
assets.filter((asset) => asset.kind === kind),
|
||||
currentId,
|
||||
companyId,
|
||||
user,
|
||||
(next) => void onPick(next?.id ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
function openAssetPickerModal(
|
||||
kind: CompanyAsset["kind"],
|
||||
assets: CompanyAsset[],
|
||||
@@ -76,6 +196,7 @@ function openAssetPickerModal(
|
||||
companyId: number | null | undefined,
|
||||
user: DashboardUser,
|
||||
onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void,
|
||||
owner: { id: number; name: string } | null = null,
|
||||
): void {
|
||||
let list = assets;
|
||||
const modal = document.createElement("div");
|
||||
@@ -150,19 +271,29 @@ function openAssetPickerModal(
|
||||
mine.className = "b01-dashboard__check";
|
||||
const mineBox = document.createElement("input");
|
||||
mineBox.type = "checkbox";
|
||||
mineBox.checked = kind === "SIGNATURE";
|
||||
mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`));
|
||||
mineBox.checked = owner !== null || kind === "SIGNATURE";
|
||||
// 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다.
|
||||
mineBox.disabled = owner !== null;
|
||||
mine.append(
|
||||
mineBox,
|
||||
document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`),
|
||||
);
|
||||
const pad = kind === "SIGNATURE" ? createSignaturePad() : null;
|
||||
const add = createButton({
|
||||
label: "올리고 선택",
|
||||
onClick: async () => {
|
||||
const chosen = file.input.files?.[0];
|
||||
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
|
||||
if (!chosen) return file.setError("그림 파일을 고르세요.");
|
||||
const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null;
|
||||
if (!chosen) {
|
||||
return file.setError(
|
||||
pad ? "그림 파일을 고르거나 서명을 그리세요." : "그림 파일을 고르세요.",
|
||||
);
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("kind", kind);
|
||||
form.append("label", label.input.value.trim());
|
||||
form.append("file", chosen);
|
||||
if (mineBox.checked) form.append("user_id", String(user.id));
|
||||
if (mineBox.checked) form.append("user_id", String(owner ? owner.id : user.id));
|
||||
if (companyId) form.append("company_id", String(companyId));
|
||||
try {
|
||||
const id = await createCompanyAsset(form);
|
||||
@@ -178,7 +309,9 @@ function openAssetPickerModal(
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
|
||||
panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions);
|
||||
panel.append(heading, grid, addTitle, label.root, file.root);
|
||||
if (pad) panel.append(pad.root);
|
||||
panel.append(mine, add, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchCompanyAssets,
|
||||
processJoinRequest,
|
||||
setCompanyLogo,
|
||||
type CompanyInfo,
|
||||
type DashboardUser,
|
||||
type JoinRequest,
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
||||
import {
|
||||
openChangeRoleModal,
|
||||
@@ -41,6 +44,26 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
||||
);
|
||||
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
|
||||
if (state.user.role !== "USER") {
|
||||
const company = state.company;
|
||||
const slot = document.createElement("div");
|
||||
void fetchCompanyAssets(company.id).then((assets) => {
|
||||
slot.append(
|
||||
createAssetField(
|
||||
"회사 로고 (도면 표제란)",
|
||||
"LOGO",
|
||||
assets,
|
||||
company.logo_asset_id ?? null,
|
||||
company.id,
|
||||
state.user,
|
||||
{ onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) },
|
||||
).root,
|
||||
);
|
||||
});
|
||||
wrap.append(slot);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -122,17 +145,40 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean):
|
||||
);
|
||||
}
|
||||
|
||||
export function companyTable(companies: CompanyInfo[]): HTMLElement {
|
||||
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
|
||||
// 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||
const logoCell = (company: CompanyInfo): HTMLElement => {
|
||||
if (!user) return text("");
|
||||
const button = createButton({
|
||||
label: company.logo_asset_id ? "로고 변경…" : "로고 지정…",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void openAssetPicker(
|
||||
"LOGO",
|
||||
company.id,
|
||||
user,
|
||||
company.logo_asset_id ?? null,
|
||||
async (id) => {
|
||||
await setCompanyLogo(id, company.id);
|
||||
company.logo_asset_id = id;
|
||||
button.textContent = id ? "로고 변경…" : "로고 지정…";
|
||||
},
|
||||
),
|
||||
});
|
||||
return button;
|
||||
};
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Company"),
|
||||
L("B01_Dashboard_Field_BusinessNumber"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
"로고",
|
||||
],
|
||||
companies.map((company) => [
|
||||
text(company.name),
|
||||
text(company.business_registration_number),
|
||||
text(company.business_status),
|
||||
logoCell(company),
|
||||
]),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
|
||||
@@ -19,12 +19,18 @@ import {
|
||||
addCompanyMember,
|
||||
fetchCompanyMembers,
|
||||
fetchCompanyAssets,
|
||||
updateCompanyAsset,
|
||||
createCompanyAsset,
|
||||
setCompanyLogo,
|
||||
type DashboardUser,
|
||||
type ProjectItem,
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
|
||||
/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */
|
||||
const NEW_MEMBER = "__new__";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
@@ -122,15 +128,42 @@ export async function openEditProjectModal(
|
||||
type: "date",
|
||||
value: (project.design_date ?? "").slice(0, 10),
|
||||
});
|
||||
const memberText = (member: Member) =>
|
||||
member.position ? `${member.name} (${member.position})` : member.name;
|
||||
const personOptions = [
|
||||
{ value: "", text: "(미지정)" },
|
||||
...members.map((member) => ({
|
||||
value: String(member.id),
|
||||
text: member.position ? `${member.name} (${member.position})` : member.name,
|
||||
})),
|
||||
...members.map((member) => ({ value: String(member.id), text: memberText(member) })),
|
||||
{ value: NEW_MEMBER, text: "+ 신규 등록…" },
|
||||
];
|
||||
const person = (label: string, current: number | null | undefined) =>
|
||||
createSelectField({ label, options: personOptions, value: String(current ?? "") });
|
||||
const persons: HTMLSelectElement[] = [];
|
||||
const person = (label: string, current: number | null | undefined) => {
|
||||
const field = createSelectField({
|
||||
label,
|
||||
options: personOptions,
|
||||
value: String(current ?? ""),
|
||||
});
|
||||
persons.push(field.select);
|
||||
let last = field.select.value;
|
||||
// 「신규 등록…」은 값이 아니라 동작이다 — 계정을 만들고 그 사람을 고른 상태로 되돌린다.
|
||||
field.select.addEventListener("change", () => {
|
||||
if (field.select.value !== NEW_MEMBER) {
|
||||
last = field.select.value;
|
||||
return;
|
||||
}
|
||||
field.select.value = last;
|
||||
openAddMemberModal((member) => {
|
||||
for (const select of persons) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(member.id);
|
||||
option.textContent = memberText(member);
|
||||
select.insertBefore(option, select.options[select.options.length - 1]);
|
||||
}
|
||||
field.select.value = String(member.id);
|
||||
last = field.select.value;
|
||||
});
|
||||
});
|
||||
return field;
|
||||
};
|
||||
const pm = person("과업책임자 (도면 표제란)", project.pm_user_id);
|
||||
const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id);
|
||||
const designer = person("설계자 (도면 표제란)", project.designer_user_id);
|
||||
@@ -142,15 +175,6 @@ export async function openEditProjectModal(
|
||||
project.company_id,
|
||||
user,
|
||||
);
|
||||
const signature = createAssetField(
|
||||
"설계자 서명 (도면 표제란)",
|
||||
"SIGNATURE",
|
||||
assets,
|
||||
project.signature_asset_id,
|
||||
project.company_id,
|
||||
user,
|
||||
);
|
||||
|
||||
if (isUserOnly) {
|
||||
name.input.disabled = true;
|
||||
region.input.disabled = true;
|
||||
@@ -185,7 +209,6 @@ export async function openEditProjectModal(
|
||||
fieldLead.root,
|
||||
designer.root,
|
||||
logo.root,
|
||||
signature.root,
|
||||
);
|
||||
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
|
||||
|
||||
@@ -206,7 +229,8 @@ export async function openEditProjectModal(
|
||||
field_lead_user_id: userId(fieldLead.select),
|
||||
designer_user_id: userId(designer.select),
|
||||
logo_asset_id: logo.value(),
|
||||
signature_asset_id: signature.value(),
|
||||
// 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다.
|
||||
signature_asset_id: null,
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
});
|
||||
@@ -244,6 +268,33 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar
|
||||
const phoneVal = (target as DashboardUser).phone || "";
|
||||
const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal });
|
||||
|
||||
// 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를
|
||||
// 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다.
|
||||
const signatureSlot = document.createElement("div");
|
||||
const companyId = (target as DashboardUser).company_id ?? user.company_id;
|
||||
void fetchCompanyAssets(companyId).then((assets) => {
|
||||
const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === target.id);
|
||||
signatureSlot.append(
|
||||
createAssetField(
|
||||
"서명 (도면 표제란)",
|
||||
"SIGNATURE",
|
||||
assets,
|
||||
owned?.id ?? null,
|
||||
companyId,
|
||||
user,
|
||||
{
|
||||
owner: { id: target.id, name: target.name },
|
||||
onChange: async (assetId) => {
|
||||
if (assetId === null) return;
|
||||
const picked = assets.find((asset) => asset.id === assetId);
|
||||
if (picked)
|
||||
await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id });
|
||||
},
|
||||
},
|
||||
).root,
|
||||
);
|
||||
});
|
||||
|
||||
if (user.role === "ADMIN") {
|
||||
// ADMIN은 직책(position) / 부서(department)만 수정 정보 가능
|
||||
name.input.disabled = true;
|
||||
@@ -257,7 +308,7 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar
|
||||
|
||||
openModal(
|
||||
L("B01_Dashboard_EditUser"),
|
||||
[name.root, position.root, department.root, phone.root],
|
||||
[name.root, position.root, department.root, phone.root, signatureSlot],
|
||||
async () => {
|
||||
await updateDashboardUser(target.id, {
|
||||
name: name.input.value.trim(),
|
||||
@@ -306,18 +357,32 @@ export function openCreateCompanyModal(): void {
|
||||
});
|
||||
const address = createInputField({ label: L("B01_Dashboard_Field_Address") });
|
||||
const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") });
|
||||
// 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다.
|
||||
const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" });
|
||||
logo.input.type = "file";
|
||||
logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
|
||||
|
||||
openModal(
|
||||
L("B01_Dashboard_Modal_CreateCompany"),
|
||||
[name.root, number.root, address.root, owner.root],
|
||||
[name.root, number.root, address.root, owner.root, logo.root],
|
||||
async () => {
|
||||
if (!name.input.value.trim() || !number.input.value.trim()) return;
|
||||
await createCompany({
|
||||
const created = await createCompany({
|
||||
name: name.input.value.trim(),
|
||||
business_registration_number: number.input.value.trim(),
|
||||
business_address: address.input.value.trim() || null,
|
||||
business_owner: owner.input.value.trim() || null,
|
||||
});
|
||||
const file = logo.input.files?.[0];
|
||||
if (file && created?.company_id) {
|
||||
const form = new FormData();
|
||||
form.append("kind", "LOGO");
|
||||
form.append("label", `${name.input.value.trim()} 로고`);
|
||||
form.append("file", file);
|
||||
form.append("company_id", String(created.company_id));
|
||||
const assetId = await createCompanyAsset(form);
|
||||
await setCompanyLogo(assetId, created.company_id);
|
||||
}
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
},
|
||||
);
|
||||
@@ -361,15 +426,33 @@ export function openFindCompanyModal(): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function openAddMemberModal(): void {
|
||||
/**
|
||||
* 구성원 추가 — 이름을 넣으면 **계정이 없는 사람도 그 자리에서 만든다**
|
||||
* (2026-09-02 사용자 확정). 이름을 비우면 이미 가입한 사람을 회사에 붙이는 옛 동작이다.
|
||||
*/
|
||||
// ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`).
|
||||
// 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것.
|
||||
export function openAddMemberModal(onCreated?: (member: Member) => void): void {
|
||||
const email = createInputField({
|
||||
label: L("B01_Dashboard_Field_MemberEmail"),
|
||||
type: "email",
|
||||
required: true,
|
||||
});
|
||||
openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => {
|
||||
if (!email.input.value.trim()) return;
|
||||
await addCompanyMember(email.input.value.trim());
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
});
|
||||
const name = createInputField({ label: L("B01_Dashboard_Table_Name") });
|
||||
const position = createInputField({ label: L("B01_Dashboard_Table_Position") });
|
||||
const department = createInputField({ label: L("B01_Dashboard_Table_Department") });
|
||||
openModal(
|
||||
L("B01_Dashboard_Modal_AddMember"),
|
||||
[email.root, name.root, position.root, department.root],
|
||||
async () => {
|
||||
if (!email.input.value.trim()) return;
|
||||
const created = await addCompanyMember(email.input.value.trim(), {
|
||||
name: name.input.value.trim() || undefined,
|
||||
position: position.input.value.trim() || null,
|
||||
department: department.input.value.trim() || null,
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
if (created?.member) onCreated?.(created.member);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||
]),
|
||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true),
|
||||
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true, [
|
||||
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
|
||||
]),
|
||||
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
|
||||
|
||||
@@ -241,6 +241,18 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 서명 그리기 판 — touch-action 을 끊어야 끌기가 스크롤로 새지 않는다 */
|
||||
.b01-dashboard__sign-pad {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 3 / 1;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__check {
|
||||
display: block;
|
||||
margin: var(--spacing-8) 0 var(--spacing-16);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 014_company_logo.sql
|
||||
-- 표제란 사람·서명·로고 재배치 (2026-09-02 사용자 확정)
|
||||
--
|
||||
-- ① 서명은 프로젝트가 아니라 **사람(계정)에 붙는다** — 이름이 들어가는 자리
|
||||
-- (과업책임자·분야별책임자·설계자)의 서명을 그 사람 계정에서 읽는다.
|
||||
-- 표현 수단은 013 의 `company_assets` 그대로다: `kind='SIGNATURE'` + `user_id=<그 사람>`.
|
||||
-- 그래서 **서명 쪽은 새 컬럼이 없다**.
|
||||
-- ② 회사 로고는 **회사 등록 단계**에서 받고 이후 수정·변경한다 — 회사마다 대표 로고 1벌을
|
||||
-- 가리키는 자리가 필요해 이 컬럼을 만든다(`kind='LOGO'` + `user_id IS NULL`).
|
||||
-- ③ 프로젝트가 고른 로고(`projects.logo_asset_id`)는 그대로 두고 **덮어쓰기**로 남긴다 —
|
||||
-- 비어 있으면 회사 로고를 쓴다.
|
||||
--
|
||||
-- `projects.signature_asset_id`(013)는 이제 읽지 않는다. 컬럼은 남겨 둔다
|
||||
-- (4환경 공유 DB 라 지우는 쪽이 위험하다 — 013 과 같은 판단).
|
||||
|
||||
USE aislo_db;
|
||||
|
||||
ALTER TABLE companies
|
||||
ADD COLUMN IF NOT EXISTS logo_asset_id INT NULL
|
||||
COMMENT '회사 대표 로고 (company_assets.id, kind=LOGO)';
|
||||
Reference in New Issue
Block a user