feat(safety): 안전 자료실 UI Phase 3 — /safety 3탭(재해·법령지침·서적표준)
safety-library-1 Phase 3 슬라이스. /safety=재해 redirect, 탭=incident / law·guide 세그먼트(법령 기본 KR) / standard·book·manual·paper 프리셋. 공용 SafetyDocList(GET /documents/ material_type C-1 계약 재사용, 백엔드 무변경=freeze 정합) + Sidebar 네비 1건. 케이스 그룹핑·version_status 뱃지=API 확장 필요라 후속. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck, MessageCircle, Hash } from 'lucide-svelte';
|
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck, MessageCircle, Hash, HardHat } from 'lucide-svelte';
|
||||||
|
|
||||||
let tree = $state([]);
|
let tree = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -195,6 +195,13 @@
|
|||||||
>
|
>
|
||||||
<FolderTree size={14} /> 자료실
|
<FolderTree size={14} /> 자료실
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/safety"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors
|
||||||
|
{$page.url.pathname.startsWith('/safety') ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||||
|
>
|
||||||
|
<HardHat size={14} /> 안전 자료실
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/clause"
|
href="/clause"
|
||||||
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors
|
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script>
|
||||||
|
// 안전 자료실 (safety-library-1 Phase 3) — 재해/법령·지침/서적·표준·매뉴얼 3탭.
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ href: '/safety/incidents', label: '재해사례' },
|
||||||
|
{ href: '/safety/laws', label: '법령·지침' },
|
||||||
|
{ href: '/safety/materials', label: '서적·표준·매뉴얼' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-5 flex flex-col gap-4">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-lg font-bold text-text">안전 자료실</h1>
|
||||||
|
<p class="text-xs text-dim mt-0.5">재해사례·법령·지침·표준 — 자료유형(material_type) 축 기반</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="flex gap-1 border-b border-default" aria-label="안전 자료실 탭">
|
||||||
|
{#each TABS as tab}
|
||||||
|
<a
|
||||||
|
href={tab.href}
|
||||||
|
aria-current={$page.url.pathname === tab.href ? 'page' : undefined}
|
||||||
|
class="px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors
|
||||||
|
{$page.url.pathname === tab.href
|
||||||
|
? 'border-accent text-accent'
|
||||||
|
: 'border-transparent text-dim hover:text-text'}"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
// /safety 진입 = 재해 탭 redirect (plan: +page=재해 탭 redirect)
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
goto('/safety/incidents', { replaceState: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script>
|
||||||
|
// 안전 자료실 공용 목록 — material_type + jurisdiction 필터로 GET /documents/ 조회.
|
||||||
|
// C-1 계약: material_type 지정 = 기본 exclude(news·law_monitor·note) 해제 (documents.py list_documents).
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { addToast } from '$lib/stores/toast';
|
||||||
|
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||||
|
|
||||||
|
let { materialType, jurisdiction = '' } = $props();
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
let docs = $state([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let nextPage = $state(1);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function load(reset = false) {
|
||||||
|
loading = true;
|
||||||
|
const pageToLoad = reset ? 1 : nextPage;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('material_type', materialType);
|
||||||
|
if (jurisdiction) params.set('jurisdiction', jurisdiction);
|
||||||
|
params.set('page', String(pageToLoad));
|
||||||
|
params.set('page_size', String(PAGE_SIZE));
|
||||||
|
const result = await api(`/documents/?${params}`);
|
||||||
|
docs = reset ? result.items : [...docs, ...result.items];
|
||||||
|
total = result.total;
|
||||||
|
nextPage = pageToLoad + 1;
|
||||||
|
} catch {
|
||||||
|
addToast('error', '안전 자료 로딩 실패');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// 필터 변경 시 1페이지부터 재조회 (materialType/jurisdiction 읽기 = 반응 트리거)
|
||||||
|
void materialType;
|
||||||
|
void jurisdiction;
|
||||||
|
docs = [];
|
||||||
|
load(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasMore = $derived(docs.length < total);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#if !loading || docs.length > 0}
|
||||||
|
<p class="text-xs text-dim tabular-nums">총 {total.toLocaleString()}건</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if docs.length > 0}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each docs as doc (doc.id)}
|
||||||
|
<DocumentCard {doc} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if !loading}
|
||||||
|
<div class="py-12 text-center text-sm text-dim">
|
||||||
|
해당 조건의 자료가 없습니다.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="py-6 text-center text-sm text-dim">불러오는 중…</div>
|
||||||
|
{:else if hasMore}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => load(false)}
|
||||||
|
class="self-center px-4 py-1.5 rounded-md text-sm text-dim border border-default hover:bg-surface hover:text-text transition-colors"
|
||||||
|
>
|
||||||
|
더 보기 ({docs.length}/{total.toLocaleString()})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script>
|
||||||
|
// 재해사례 탭 — material_type=incident (KOSHA 사고사망·재해사례·CSB 등).
|
||||||
|
// 케이스 그룹핑(boardno 본문+첨부 1카드)은 API 확장 필요라 후속(DS freeze 하 백엔드 무변경).
|
||||||
|
import SafetyDocList from '../SafetyDocList.svelte';
|
||||||
|
|
||||||
|
const JURISDICTIONS = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'KR', label: 'KR' },
|
||||||
|
{ value: 'US', label: 'US' },
|
||||||
|
];
|
||||||
|
let jurisdiction = $state('');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-1.5" role="group" aria-label="관할 필터">
|
||||||
|
{#each JURISDICTIONS as j}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (jurisdiction = j.value)}
|
||||||
|
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors
|
||||||
|
{jurisdiction === j.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||||
|
>
|
||||||
|
{j.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SafetyDocList materialType="incident" {jurisdiction} />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script>
|
||||||
|
// 법령·지침 탭 — 법령(law, 버전체인 current 만 코퍼스 노출) / 지침(guide, KOSHA GUIDE 등).
|
||||||
|
// 법령 기본 관할 = KR (plan: country 누락 = KR 정규화). version_status 뱃지는 API 확장 후속.
|
||||||
|
import SafetyDocList from '../SafetyDocList.svelte';
|
||||||
|
|
||||||
|
const KINDS = [
|
||||||
|
{ value: 'law', label: '법령' },
|
||||||
|
{ value: 'guide', label: '지침' },
|
||||||
|
];
|
||||||
|
const JURISDICTIONS = [
|
||||||
|
{ value: 'KR', label: 'KR' },
|
||||||
|
{ value: 'US', label: 'US' },
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
];
|
||||||
|
let kind = $state('law');
|
||||||
|
let jurisdiction = $state('KR');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div class="flex items-center gap-1" role="group" aria-label="자료유형">
|
||||||
|
{#each KINDS as k}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (kind = k.value)}
|
||||||
|
class="px-3 py-1 rounded-md text-sm font-medium transition-colors
|
||||||
|
{kind === k.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||||
|
>
|
||||||
|
{k.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5" role="group" aria-label="관할 필터">
|
||||||
|
{#each JURISDICTIONS as j}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (jurisdiction = j.value)}
|
||||||
|
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors
|
||||||
|
{jurisdiction === j.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||||
|
>
|
||||||
|
{j.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SafetyDocList materialType={kind} {jurisdiction} />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script>
|
||||||
|
// 서적·표준·매뉴얼 탭 — 필터 프리셋(전용 뷰는 50건+ 게이트 뒤, plan Phase 3).
|
||||||
|
import SafetyDocList from '../SafetyDocList.svelte';
|
||||||
|
|
||||||
|
const KINDS = [
|
||||||
|
{ value: 'standard', label: '표준 (NB 등)' },
|
||||||
|
{ value: 'book', label: '서적' },
|
||||||
|
{ value: 'manual', label: '매뉴얼' },
|
||||||
|
{ value: 'paper', label: '논문' },
|
||||||
|
];
|
||||||
|
let kind = $state('standard');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-1" role="group" aria-label="자료유형">
|
||||||
|
{#each KINDS as k}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (kind = k.value)}
|
||||||
|
class="px-3 py-1 rounded-md text-sm font-medium transition-colors
|
||||||
|
{kind === k.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||||
|
>
|
||||||
|
{k.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SafetyDocList materialType={kind} />
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user