Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 29 additions & 16 deletions src/api/workerLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import { apiFetch } from './client'
import type { DocumentType } from './documents'

export type WorkerResponseType =
| 'ACKNOWLEDGED'
| 'QUESTION'
| 'NOT_UNDERSTOOD'
| 'DOCUMENT_SUBMITTED'
| 'DIFFICULT'
'ACKNOWLEDGED' | 'QUESTION' | 'NOT_UNDERSTOOD' | 'DOCUMENT_SUBMITTED' | 'DIFFICULT'

export interface WorkerLinkIssueBody {
expires_in_hours?: number
Expand All @@ -16,14 +12,15 @@ export interface WorkerLinkIssueBody {
export interface WorkerLinkIssueResponse {
worker_link_id: string
worker_url: string | null
worker_link_token: string | null
expires_at: string
delivery_status: WorkerLinkDeliveryStatus
sent_at: string | null
already_issued: boolean
}

export type WorkerLinkStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED'
export type WorkerLinkDeliveryStatus = 'NOT_SENT' | 'SENT'
export type WorkerLinkDeliveryStatus = 'NOT_SENT' | 'SENDING' | 'REVIEW_REQUIRED' | 'SENT'

export interface WorkerLinkDeliveryResponse {
worker_link_id: string
Expand Down Expand Up @@ -92,12 +89,8 @@ export function issueWorkerLink(
})
}

export function fetchTaskWorkerLinkDelivery(
taskId: string,
): Promise<WorkerLinkDeliveryResponse> {
return apiFetch<WorkerLinkDeliveryResponse>(
`/tasks/${encodeURIComponent(taskId)}/worker-link`,
)
export function fetchTaskWorkerLinkDelivery(taskId: string): Promise<WorkerLinkDeliveryResponse> {
return apiFetch<WorkerLinkDeliveryResponse>(`/tasks/${encodeURIComponent(taskId)}/worker-link`)
}

export function markWorkerLinkSent(workerLinkId: string): Promise<WorkerLinkDeliveryResponse> {
Expand All @@ -107,6 +100,27 @@ export function markWorkerLinkSent(workerLinkId: string): Promise<WorkerLinkDeli
)
}

export interface WorkerLinkSmsDeliveryBody {
recipient_phone: string
worker_link_token: string
}

// 링크 발급 때 쓴 것과 같은 idempotencyKey를 넘겨야 서버가 요청-토큰 일치를 검증한다.
export function sendWorkerLinkSms(
workerLinkId: string,
body: WorkerLinkSmsDeliveryBody,
idempotencyKey: string,
): Promise<WorkerLinkDeliveryResponse> {
return apiFetch<WorkerLinkDeliveryResponse>(
`/worker-links/${encodeURIComponent(workerLinkId)}/sms-deliveries`,
{
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(body),
},
)
}

export function fetchWorkerLink(token: string): Promise<WorkerLinkViewResponse> {
return apiFetch<WorkerLinkViewResponse>(`/public/worker-links/${encodeURIComponent(token)}`, {
skipAuthRetry: true,
Expand Down Expand Up @@ -161,10 +175,9 @@ export function fetchTaskWorkerResponses(
}

export function markTaskWorkerResponsesRead(taskId: string): Promise<void> {
return apiFetch<void>(
`/tasks/${encodeURIComponent(taskId)}/worker-responses/read`,
{ method: 'POST' },
)
return apiFetch<void>(`/tasks/${encodeURIComponent(taskId)}/worker-responses/read`, {
method: 'POST',
})
}

export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string {
Expand Down
50 changes: 49 additions & 1 deletion src/pages/CaseDetailPage/CaseDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
markTaskWorkerResponsesRead,
markWorkerLinkSent,
resolveWorkerPortalUrl,
sendWorkerLinkSms,
type WorkerLinkDeliveryResponse,
type WorkerResponseType,
} from '../../api/workerLinks'
Expand Down Expand Up @@ -197,6 +198,10 @@ export function CaseDetailPage() {
const [lastReissue, setLastReissue] = useState<ReissueSubmission | null>(null)
const [issuedWorkerUrl, setIssuedWorkerUrl] = useState<string | null>(null)
const [issuedExpiresAt, setIssuedExpiresAt] = useState<string | null>(null)
const [issuedWorkerLinkToken, setIssuedWorkerLinkToken] = useState<string | null>(null)
const [issuedIdempotencyKey, setIssuedIdempotencyKey] = useState<string | null>(null)
const [sendingLinkSms, setSendingLinkSms] = useState(false)
const [linkSmsStatusMessage, setLinkSmsStatusMessage] = useState<string | null>(null)
const [localWorkerLinkDelivery, setLocalWorkerLinkDelivery] = useState<{
taskId: string
data: WorkerLinkDeliveryResponse
Expand Down Expand Up @@ -577,12 +582,13 @@ export function CaseDetailPage() {
async function handleSubmitLinkReissue(submission: ReissueSubmission) {
if (!task || actionPending) return
const expiryHours = submission.expiry === '24시간' ? 24 : submission.expiry === '7일' ? 168 : 72
const idempotencyKey = crypto.randomUUID()
setActionPending(true)
try {
const issued = await issueWorkerLink(
task.task_id,
{ expires_in_hours: expiryHours, rotate_existing: true },
crypto.randomUUID(),
idempotencyKey,
)
setLastReissue(submission)
setIssuedWorkerUrl(
Expand All @@ -591,6 +597,9 @@ export function CaseDetailPage() {
: null,
)
setIssuedExpiresAt(issued.expires_at)
setIssuedWorkerLinkToken(issued.worker_link_token)
setIssuedIdempotencyKey(idempotencyKey)
setLinkSmsStatusMessage(null)
setLocalWorkerLinkDelivery({
taskId: task.task_id,
data: {
Expand Down Expand Up @@ -650,6 +659,37 @@ export function CaseDetailPage() {
}
}

async function handleSendLinkSms(recipientPhone: string) {
if (!task || sendingLinkSms) return
const currentDelivery =
localWorkerLinkDelivery?.taskId === task.task_id
? localWorkerLinkDelivery.data
: workerLinkDelivery
if (!currentDelivery || !issuedWorkerLinkToken || !issuedIdempotencyKey) return

setSendingLinkSms(true)
setLinkSmsStatusMessage(null)
try {
const delivered = await sendWorkerLinkSms(
currentDelivery.worker_link_id,
{ recipient_phone: recipientPhone, worker_link_token: issuedWorkerLinkToken },
issuedIdempotencyKey,
)
setLocalWorkerLinkDelivery({ taskId: task.task_id, data: delivered })
refetchWorkerLinkDelivery()
refetchActivities()
setLinkSmsStatusMessage('문자를 발송했습니다.')
showToast('근로자에게 링크를 문자로 발송했습니다.')
} catch (error) {
setLinkSmsStatusMessage(
error instanceof ApiError ? getErrorMessage(error) : '문자를 발송하지 못했습니다.',
)
refetchWorkerLinkDelivery()
} finally {
setSendingLinkSms(false)
}
}

async function handleMarkResponsesRead() {
if (!task || markingResponsesRead) return
setMarkingResponsesRead(true)
Expand Down Expand Up @@ -1470,6 +1510,14 @@ export function CaseDetailPage() {
Boolean(issuedWorkerUrl) && currentWorkerLinkDelivery?.delivery_status === 'NOT_SENT'
}
onRecordDelivery={() => handleOpenDeliveryConfirm('reissued')}
canSendSms={
Boolean(issuedWorkerLinkToken) &&
(currentWorkerLinkDelivery?.delivery_status === 'NOT_SENT' ||
currentWorkerLinkDelivery?.delivery_status === undefined)
}
smsSending={sendingLinkSms}
smsStatusMessage={linkSmsStatusMessage}
onSendSms={handleSendLinkSms}
onClose={() => setLinkOverlay('none')}
/>
<LinkDeliveryConfirmModal
Expand Down
49 changes: 44 additions & 5 deletions src/pages/CaseDetailPage/overlays/LinkReissuedModal.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Modal } from '../../../components/ui/Modal/Modal'
import { useToastStore } from '../../../store/toastStore'
import { getWorkerRequestStateViewModel } from '../../../view-models/workerRequestStateViewModel'
Expand All @@ -11,6 +12,10 @@ export interface LinkReissuedModalProps {
expiresAt: string | null
canRecordDelivery: boolean
onRecordDelivery: () => void
canSendSms: boolean
smsSending: boolean
smsStatusMessage: string | null
onSendSms: (recipientPhone: string) => void
onClose: () => void
}

Expand All @@ -21,10 +26,15 @@ export function LinkReissuedModal({
expiresAt,
canRecordDelivery,
onRecordDelivery,
canSendSms,
smsSending,
smsStatusMessage,
onSendSms,
onClose,
}: LinkReissuedModalProps) {
const showToast = useToastStore((state) => state.showToast)
const requestState = getWorkerRequestStateViewModel({})
const [recipientPhone, setRecipientPhone] = useState('')

async function handleCopyLink() {
if (!workerUrl) return
Expand All @@ -36,14 +46,18 @@ export function LinkReissuedModal({
}
}

function handleSendSms() {
if (!canSendSms || smsSending || !recipientPhone.trim()) return
onSendSms(recipientPhone.trim())
}

return (
<Modal open={open} onClose={onClose} title="새 링크가 준비되었습니다" size="wide">
<p className={styles.description}>
자동 발송되지 않습니다. 링크를 복사해 직접 전달해 주세요.
</p>
<p className={styles.description}>문자로 바로 보내거나, 링크를 복사해 직접 전달해 주세요.</p>

<p className={styles.readyBanner}>
✓ {requestState.label} · {submission?.expiry} · {expiresAt ? new Date(expiresAt).toLocaleString('ko-KR') : '만료시각 확인 필요'}까지
✓ {requestState.label} · {submission?.expiry} ·{' '}
{expiresAt ? new Date(expiresAt).toLocaleString('ko-KR') : '만료시각 확인 필요'}까지
</p>

<div className={styles.plainRow}>
Expand All @@ -53,11 +67,36 @@ export function LinkReissuedModal({
</button>
</div>

{canSendSms && (
<div className={styles.plainRow}>
<input
type="tel"
className={styles.textInput}
placeholder="01012345678"
value={recipientPhone}
onChange={(event) => setRecipientPhone(event.target.value)}
disabled={smsSending}
aria-label="근로자 휴대전화 번호"
/>
<button
type="button"
className={styles.textLink}
onClick={handleSendSms}
disabled={smsSending || !recipientPhone.trim()}
>
{smsSending ? '발송 중…' : '문자로 보내기'}
</button>
</div>
)}
{smsStatusMessage && <p className={styles.policyBannerText}>{smsStatusMessage}</p>}

<p className={styles.fieldLabel}>재발급 사유</p>
<p className={styles.plainValue}>{submission?.reason}</p>

<div className={styles.policyBanner}>
<p className={styles.policyBannerText}>{requestState.description} SMS·메신저로 직접 전달해 주세요.</p>
<p className={styles.policyBannerText}>
{requestState.description} SMS·메신저로 직접 전달해 주세요.
</p>
</div>

<div className={styles.actionRow}>
Expand Down