From df16f34a88ccc349b3c8bd5758d754b7e1a17d69 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 11 Aug 2026 10:44:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20=EA=B7=BC=EB=A1=9C=EC=9E=90=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20SMS=20=EB=B0=9C=EC=86=A1=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=EC=9E=91=EC=97=85=20=ED=91=9C=EC=8B=9C=20(server#134?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #309. server#134가 병합되면 sendWorkerLinkSms 구현과 LinkDeliveryConfirmModal 액션 추가 예정. --- src/api/workerLinks.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts index 298da48..a395408 100644 --- a/src/api/workerLinks.ts +++ b/src/api/workerLinks.ts @@ -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 @@ -92,12 +88,8 @@ export function issueWorkerLink( }) } -export function fetchTaskWorkerLinkDelivery( - taskId: string, -): Promise { - return apiFetch( - `/tasks/${encodeURIComponent(taskId)}/worker-link`, - ) +export function fetchTaskWorkerLinkDelivery(taskId: string): Promise { + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/worker-link`) } export function markWorkerLinkSent(workerLinkId: string): Promise { @@ -161,10 +153,9 @@ export function fetchTaskWorkerResponses( } export function markTaskWorkerResponsesRead(taskId: string): Promise { - return apiFetch( - `/tasks/${encodeURIComponent(taskId)}/worker-responses/read`, - { method: 'POST' }, - ) + return apiFetch(`/tasks/${encodeURIComponent(taskId)}/worker-responses/read`, { + method: 'POST', + }) } export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string): string { @@ -172,3 +163,7 @@ export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string) if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString() return `${origin}/worker-portal/${encodeURIComponent(workerUrlOrToken)}` } + +// TODO(backend): POST /worker-links/{workerLinkId}/sms-deliveries — server#134 병합 후 연동 +// (이슈 #309). 응답 상태 NOT_SENT/SENDING/REVIEW_REQUIRED/SENT, SENDING·REVIEW_REQUIRED일 때 +// 재발송 금지. From fdf324420e73793a342bb812920c590d72f974f0 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 11 Aug 2026 13:07:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EA=B7=BC=EB=A1=9C=EC=9E=90=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EB=A7=81=ED=81=AC=20SMS=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EC=97=B0=EB=8F=99=20(server#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendWorkerLinkSms API 함수 추가, delivery_status에 SENDING/REVIEW_REQUIRED 추가 - LinkReissuedModal에 전화번호 입력 + '문자로 보내기' 액션, 발송 상태 메시지 표시 - 링크 발급 시 받은 worker_link_token/idempotency key를 보관했다가 SMS 발송에 재사용 (같은 idempotency key로 요청-토큰 일치를 서버가 검증) closes #309 --- src/api/workerLinks.ts | 28 +++++++++-- src/pages/CaseDetailPage/CaseDetailPage.tsx | 50 ++++++++++++++++++- .../overlays/LinkReissuedModal.tsx | 49 ++++++++++++++++-- 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/api/workerLinks.ts b/src/api/workerLinks.ts index a395408..b596e67 100644 --- a/src/api/workerLinks.ts +++ b/src/api/workerLinks.ts @@ -12,6 +12,7 @@ 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 @@ -19,7 +20,7 @@ export interface WorkerLinkIssueResponse { } 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 @@ -99,6 +100,27 @@ export function markWorkerLinkSent(workerLinkId: string): Promise { + return apiFetch( + `/worker-links/${encodeURIComponent(workerLinkId)}/sms-deliveries`, + { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + body: JSON.stringify(body), + }, + ) +} + export function fetchWorkerLink(token: string): Promise { return apiFetch(`/public/worker-links/${encodeURIComponent(token)}`, { skipAuthRetry: true, @@ -163,7 +185,3 @@ export function resolveWorkerPortalUrl(workerUrlOrToken: string, origin: string) if (workerUrlOrToken.startsWith('/')) return new URL(workerUrlOrToken, origin).toString() return `${origin}/worker-portal/${encodeURIComponent(workerUrlOrToken)}` } - -// TODO(backend): POST /worker-links/{workerLinkId}/sms-deliveries — server#134 병합 후 연동 -// (이슈 #309). 응답 상태 NOT_SENT/SENDING/REVIEW_REQUIRED/SENT, SENDING·REVIEW_REQUIRED일 때 -// 재발송 금지. diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index 476f9d2..f5529ef 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -30,6 +30,7 @@ import { markTaskWorkerResponsesRead, markWorkerLinkSent, resolveWorkerPortalUrl, + sendWorkerLinkSms, type WorkerLinkDeliveryResponse, type WorkerResponseType, } from '../../api/workerLinks' @@ -197,6 +198,10 @@ export function CaseDetailPage() { const [lastReissue, setLastReissue] = useState(null) const [issuedWorkerUrl, setIssuedWorkerUrl] = useState(null) const [issuedExpiresAt, setIssuedExpiresAt] = useState(null) + const [issuedWorkerLinkToken, setIssuedWorkerLinkToken] = useState(null) + const [issuedIdempotencyKey, setIssuedIdempotencyKey] = useState(null) + const [sendingLinkSms, setSendingLinkSms] = useState(false) + const [linkSmsStatusMessage, setLinkSmsStatusMessage] = useState(null) const [localWorkerLinkDelivery, setLocalWorkerLinkDelivery] = useState<{ taskId: string data: WorkerLinkDeliveryResponse @@ -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( @@ -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: { @@ -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) @@ -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')} /> void + canSendSms: boolean + smsSending: boolean + smsStatusMessage: string | null + onSendSms: (recipientPhone: string) => void onClose: () => void } @@ -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 @@ -36,14 +46,18 @@ export function LinkReissuedModal({ } } + function handleSendSms() { + if (!canSendSms || smsSending || !recipientPhone.trim()) return + onSendSms(recipientPhone.trim()) + } + return ( -

- 자동 발송되지 않습니다. 링크를 복사해 직접 전달해 주세요. -

+

문자로 바로 보내거나, 링크를 복사해 직접 전달해 주세요.

- ✓ {requestState.label} · {submission?.expiry} · {expiresAt ? new Date(expiresAt).toLocaleString('ko-KR') : '만료시각 확인 필요'}까지 + ✓ {requestState.label} · {submission?.expiry} ·{' '} + {expiresAt ? new Date(expiresAt).toLocaleString('ko-KR') : '만료시각 확인 필요'}까지

@@ -53,11 +67,36 @@ export function LinkReissuedModal({
+ {canSendSms && ( +
+ setRecipientPhone(event.target.value)} + disabled={smsSending} + aria-label="근로자 휴대전화 번호" + /> + +
+ )} + {smsStatusMessage &&

{smsStatusMessage}

} +

재발급 사유

{submission?.reason}

-

{requestState.description} SMS·메신저로 직접 전달해 주세요.

+

+ {requestState.description} SMS·메신저로 직접 전달해 주세요. +