/* 연간 관리 — 구독 서비스 목록 (PDF 6-2)
* 시설관리의 WorkerRegistry(①~⑩ 슬라이드 패널)와 동형이되,
* 서비스는 상거래 메타(관리구역·계약기간·월정액)를 갖는 카탈로그다.
* DetailScreen이 axis.kind==='service' 일 때 이 패널을 등록부로 띄운다. */
const SVC_NUM = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
function ServiceCatalog({ schoolName, workers, activeId, isSafe, onPick, onAdd, onRemove, onClose }) {
const services = workers; // DetailScreen이 항목 배열을 workers prop으로 전달(공용 시그니처)
const rows = [];
for (let i = 0; i < 10; i++) rows.push(services[i] || null);
const total = services.reduce((n, s) => n + (Number(s.fee) || 0), 0);
return (
);
}
window.ServiceCatalog = ServiceCatalog;
/* ===================== 연간 관리 — 학교별 구독 현황 + 신규 요청 (단기대체와 동일 패턴) ===================== */
const SUBSCR_REQ_KEY = (s) => "ss_subscr_req_" + s;
function subscrReqGet(school) { try { const v = JSON.parse(localStorage.getItem(SUBSCR_REQ_KEY(school))); if (Array.isArray(v)) return v; } catch (e) {} return []; }
function subscrReqSet(school, list) { try { localStorage.setItem(SUBSCR_REQ_KEY(school), JSON.stringify(list)); } catch (e) {} }
const SUBSCR_TYPES = ["옥외시설물 관리", "청소 용역", "방역·소독", "전지·예초", "시설 점검", "기타"];
const SUBSCR_SL = { draft: "작성 중", submitted: "제출됨", received: "접수", accepted: "서비스 개시" };
function subscrBlank() {
return { id: "sr_" + Date.now().toString(36), status: "draft", svcName: "", svcEtc: "", zone: "",
periodStart: "", periodEnd: "", manager: "", contact: "", detail: "", note: "", createdAt: Date.now() };
}
/* 신규 요청서 — 우측 서랍 (학교 작성 / 세이프스쿨 읽기 + 서비스 개시) */
function SubsRequestForm({ initial, readOnly, onSave, onSubmit, onClose, onAccept }) {
const [r, setR] = React.useState(() => ({ ...subscrBlank(), ...initial }));
const set = (k, v) => setR((s) => ({ ...s, [k]: v }));
const ro = !!readOnly;
useLucideSS();
const txt = (label, key, ph) => (
);
const canSubmit = (r.svcName && r.svcName.trim()) || (r.svcEtc && r.svcEtc.trim());
return (
);
}
/* 학교별 구독 현황 — 구독중 서비스 + 신규 요청 (세이프스쿨·학교 공용) */
function SubscriptionStatusScreen({ school, role, onBack, onOpenBoard, toast }) {
const [flt, setFlt] = React.useState("all");
const [, setTick] = React.useState(0);
const [drawer, setDrawer] = React.useState(null); // { req, mode }
useLucideSS();
const name = (school && school.name) || window.SS_DATA.MY_SCHOOL.name;
const isSafe = role === "safe";
const reqs = subscrReqGet(name).filter((r) => isSafe ? r.status !== "draft" : true);
const services = window.SS_SERVICES.get(name);
const svcRows = services.map((s) => ({ type: "active", sid: s.id, title: s.name, sub: window.ssFeeLabel(s), period: s.period || "", extra: s.zone || "" }));
const reqRows = reqs.filter((r) => r.status !== "accepted").map((r) => ({
type: "req", status: r.status, id: r.id,
title: (r.svcName || r.svcEtc || "분야 미정") + " 요청",
sub: r.manager || "", period: [r.periodStart, r.periodEnd].filter(Boolean).join(" ~ "), extra: r.zone || "",
}));
const order = { submitted: 0, received: 1, draft: 2 };
reqRows.sort((a, b) => (order[a.status] || 9) - (order[b.status] || 9));
const all = reqRows.concat(svcRows);
const cnt = { req: reqRows.length, active: svcRows.length };
const list = flt === "all" ? all : all.filter((r) => (flt === "req" ? r.type === "req" : r.type === "active"));
const openReq = (id) => {
const req = subscrReqGet(name).find((x) => x.id === id);
if (!req) return;
setDrawer({ req, mode: (!isSafe && req.status === "draft") ? "edit" : "view" });
};
const saveDraft = (r) => {
const list0 = subscrReqGet(name);
subscrReqSet(name, list0.some((x) => x.id === r.id) ? list0.map((x) => x.id === r.id ? r : x) : [...list0, r]);
setTick((t) => t + 1); setDrawer(null);
if (toast) toast("신규 요청서를 임시 저장했습니다 — 목록에서 이어 작성할 수 있어요");
};
const submitReq = (r) => {
const patch = { ...r, status: "submitted", submittedAt: Date.now() };
const list0 = subscrReqGet(name);
subscrReqSet(name, list0.some((x) => x.id === r.id) ? list0.map((x) => x.id === r.id ? patch : x) : [...list0, patch]);
setTick((t) => t + 1); setDrawer(null);
if (toast) toast("신규 요청을 제출했습니다 — 세이프스쿨이 확인 후 연락드립니다");
};
const accept = (r) => {
const svc = { id: window.SS_SERVICES.newId(), name: r.svcName || r.svcEtc || "새 서비스", zone: r.zone || "",
period: [r.periodStart, r.periodEnd].filter(Boolean).join(" ~ ") || "2026.3.1 ~ 2027.2.28",
fee: 0, feeNote: "vat 포함", team: "", teamTel: "1533-2075", safetyMgr: "", schoolMgr: r.manager || "", tenure: "1년 차",
categories: ["기타"] };
window.SS_SERVICES.add(name, svc);
window.SS_SERVICES.setLast(name, svc.id);
subscrReqSet(name, subscrReqGet(name).map((x) => x.id === r.id ? { ...x, status: "accepted", serviceId: svc.id } : x));
setTick((t) => t + 1); setDrawer(null);
if (toast) toast("서비스를 개시했습니다 — 상황판에서 계약 정보를 채워 주세요");
onOpenBoard(svc.id);
};
const badge = (r) => r.type === "active" ? ["st-active", "구독중"] : r.status === "submitted" ? ["st-new", "요청 · 제출됨"] : r.status === "received" ? ["st-scheduled", "요청 · 접수"] : ["st-done", "요청 · " + (SUBSCR_SL[r.status] || r.status)];
return (
연간 관리 · 연간 관리 리스트
{name} 연간 관리 리스트
이 학교의 구독 중인 서비스와 신규 요청을 한눈에 봅니다. 구독 서비스를 누르면 해당 서비스의 관련 업무 페이지로 이동하고, 요청서를 누르면 오른쪽 서랍에서 내용을 볼 수 있습니다.
{!isSafe && (
)}
전체 {all.length}건
구독 서비스 전체 현황
신규 요청 {cnt.req}건 · 구독중 {cnt.active}건
{list.map((r, i) => {
const [cls, label] = badge(r);
return (
);
})}
{list.length === 0 &&
해당 상태의 건이 없습니다.{!isSafe ? " 「신규 요청 작성」으로 시작해 보세요." : ""}
}
{drawer && (
setDrawer(null)} onSave={saveDraft} onSubmit={submitReq}
onAccept={isSafe ? accept : undefined} />
)}
);
}
Object.assign(window, { SubscriptionStatusScreen, SubsRequestForm });